Compare commits

..
44 Commits
Author SHA1 Message Date
ybc❤️zyrand猷诺 91af2bc3b8 fix(install): add .hermes/skills to AGENT_DIRS (#188) (#221)
* fix(install): add .hermes/skills to AGENT_DIRS (#188)

dws 安装后未自动复制 skill 到 .hermes/skills/。在 4 份硬编码的
AGENT_DIRS 清单(build/npm/install.js、scripts/install.sh、
scripts/install.ps1、scripts/install-skills.sh)末尾追加
.hermes/skills,与现有'父目录守卫'逻辑天然兼容:

- 已安装 Hermes 的用户:自动获得 ~/.hermes/skills/dws
- 未安装 Hermes 的用户:父目录不存在则跳过,零副作用

Closes #188

* fix(install): cover remaining 4 AGENT_DIRS sources for .hermes/skills (review feedback)

Address review feedback from #221: the AGENT_DIRS list actually has 8 sources
in the repo, not 4. Without this commit, dws upgrade users and Homebrew users
would still NOT get .hermes/skills/dws populated even after the previous
4-script fix landed.

Fixes (functional):
- internal/upgrade/paths.go (knownSkillDirs):
    Append .hermes/skills so 'dws upgrade' refreshes ~/.hermes/skills/dws/
    for users who have Hermes installed.
- build/homebrew.rb.tmpl (post_install targets):
    Append .hermes/skills/dws so brew users also get the skill copied to
    ~/.hermes/skills/dws on post_install.

Fixes (test / smoke coverage):
- test/scripts/package_script_test.go (expectedPackagedSkillTargets):
    Append .hermes/skills/dws so future regressions (e.g. someone removes
    .hermes from install.js) are caught by CI.
- scripts/release/verify-package-managers.sh (HOME_AGENT_PARENTS / HOME_SKILL_TARGETS):
    Append .hermes to both lists so the release-time npm/brew smoke test
    actually asserts ~/.hermes/skills/dws/SKILL.md exists.

Documentation:
- internal/upgrade/paths.go: expanded the 'Kept in sync with' comment from
    a single file reference (build/npm/install.js) to all 7 in-sync sources,
    so future maintainers don't have to rediscover this list.

Verification:
- go vet ./internal/upgrade/... ./test/scripts/...   PASS
- go build ./...                                     PASS
- sh -n scripts/install.sh                           PASS
- sh -n scripts/install-skills.sh                    PASS
- sh -n scripts/release/verify-package-managers.sh   PASS
- node --check build/npm/install.js                  PASS

---------

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-05-07 11:07:27 +08:00
ybc❤️zyrand猷诺 e2e8b3bf52 fix(attendance): summary 命令新增 --stats-type flag,修复 C0002 报错 (#228)
* fix(attendance): add --stats-type flag to summary to fix C0002 error

The MCP tool get_attendance_summary requires statsType in QueryUserAttendVO
at the server-side business layer (DingTalk schema marks it required:[] but
service rejects with C0002 'statistics type error' when omitted).

Changes:
- internal/helpers/attendance.go: add --stats-type flag (week/month);
  conditionally write statsType into QueryUserAttendVO when provided;
  update Long description and flag help to mark --stats-type as required
- test/cli_compat/attendance_test.go: add execSummaryDryRun helper and
  3 new tests verifying statsType is correctly threaded through VO
- skills/references/products/attendance.md: document --stats-type flag
  with warning about server-side mandatory enforcement

Verification:
- go build ./...                                                  PASS
- go vet ./internal/helpers/... ./test/cli_compat/...              PASS
- go test -run TestAttendanceSummary_should_pass_stats_type_*      PASS (3/3)
- go test -run TestAttendanceSummary_should_not_pass_stats_type_*  PASS
- Real CLI: dws attendance summary --stats-type month              success:true
  (C0002 fully gone; full attendance data returned)

Closes #227

* fix(attendance): enforce --stats-type as required at CLI layer (review feedback)

Address review feedback from #228:

1. Fail-fast validation: --stats-type is now required at the CLI layer
   instead of being conditionally written into VO. Previously the doc
   said 'required' but RunE silently let omission through, causing the
   same C0002 the fix was supposed to prevent.

2. Enum validation: only 'week' or 'month' are accepted; any other value
   is rejected at the CLI layer instead of being forwarded to the server.

3. Cosmetic: split the single-line Long description into multiple lines
   for readability.

Test changes:
- Replaced TestAttendanceSummary_should_not_pass_stats_type_when_omitted
  with two semantically-correct tests:
    * TestAttendanceSummary_should_error_when_stats_type_missing
    * TestAttendanceSummary_should_error_when_stats_type_invalid
- Updated other TestAttendanceSummary_* tests to pass --stats-type=month
  to match the new fail-fast contract.
- Renamed should_pass_only_user_flag to should_pass_user_id_through_vo
  (the old name no longer makes sense now that --stats-type is mandatory).

Skill doc:
- skills/references/products/attendance.md: clarified that the CLI now
  rejects missing/invalid --stats-type at the client side (won't even
  reach the server).

Verification:
- go build ./...                                    PASS
- go vet ./internal/helpers/... ./test/cli_compat/  PASS
- go test -run 'TestAttendanceSummary_should_(pass_stats_type|error_when_stats_type)' -v
                                                    PASS (4/4)
- dws attendance summary --help                     new layout shown

---------

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-05-07 10:50:24 +08:00
ybc❤️zyrand猷诺 a652b90fd4 fix: resolve cross-product tool routing collision (issue #219) (#220)
When two MCP servers register tools with the same name (e.g. both drive
and doc have 'create_folder'), the tool-level endpoint map uses
last-writer-wins, causing invocations to route to the wrong server.

Fix: swap Priority 1 (product-level) and Priority 2 (tool-level) in
directRuntimeEndpoint so that when the caller already knows the
productID, the product endpoint is authoritative. Tool-level lookup
remains as a fallback for cases where productID is empty.

This fixes 'dws drive mkdir' and 'dws drive download' being silently
routed to the doc MCP server instead of the drive MCP server.

Closes #219

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-05-05 16:21:51 +08:00
修雨 7a868ddf39 docs(help): document --contents.key=field_name and mark required flags (#106 #107) (#217)
- chat message send-by-bot --robot-code/--title/--text: append (必填) marker
  (RunE already enforces these as required; help text was missing the tag,
  causing downstream MCP wrappers to generate schemas with required fields
  missing).
- report create --contents: description appended "key must exactly equal the
  template field_name (look it up via report template detail --name <template>)";
  Long now warns about the API's SYSTEM_ERROR on key mismatch; Examples
  rewritten as a two-step pipeline (template detail → create).

Closes #106
Closes #107
2026-05-04 17:57:23 +08:00
ybc❤️zyrand猷诺 89d7c5f11b docs: clarify message list pagination to prevent nextCursor misuse (fixes #195) (#218)
* docs: clarify message list pagination to prevent nextCursor misuse (fixes #195)

- Add detailed pagination walkthrough section for 'message list' in chat.md
- Add comparison table distinguishing 'message list' (--time with createTime)
  from 'message list-all' (--cursor with nextCursor)
- Add warning about common error: using nextCursor as --time value
  causes infinite loop returning same page

* docs: fix --time required label and add --forward misuse warning (fixes #195)

---------

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-05-04 16:38:09 +08:00
ybc❤️zyrand猷诺 efb61cae02 fix: restore unconditional MCP re-fetch in login to prevent stale clientId errors (#213)
PR #184 merge conflict resolution inadvertently introduced a conditional
branch that preserved stale clientID from previous login sessions instead
of always re-fetching from MCP server. This caused exchangeCode() to use
direct DingTalk API mode (which requires clientSecret) instead of MCP
proxy mode, resulting in 'clientId或者clientSecret错误' errors.

This commit restores the original PR #157 logic: both DeviceFlowProvider
and OAuthProvider unconditionally call resetCredentialState() followed by
FetchClientIDFromMCP() + SetClientIDFromMCP(), ensuring exchangeCode()
always uses the MCP proxy path regardless of prior login state.

Root cause: the conditional branch treated any non-empty clientID (from
runtimeClientID or app.json) as a user-provided --client-id flag value,
skipping MCP re-fetch and leaving clientIDFromMCP=false after reset.

Affected scenarios:
- OAuth login → device flow login
- Any login → --force login
- New terminal with existing app.json → device flow login

Fixes regression introduced in PR #184 (commit 46192d6).
Restores fix from PR #157 (commit ad33a46).

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-05-04 09:26:25 +08:00
ybc❤️zyrand猷诺 fb88c6ace9 docs(todo): 增强待办命令帮助文本,补充字段语义说明 (#205)
基于实际 API 返回验证,所有描述均经过 dws 命令实测确认。

- todo task get: 新增 Long 说明
  · creatorId/executorIds/participantIds/modifierId 是待办内部短数字 ID
    (如 6380165826),非通讯录 userId (如 035551044606950179),已实测确认
  · 提示 creatorInfo/executorInfos/participantInfos 包含 name 属性可获取姓名
  · bizTag/source 返回 teambition 是引擎实现标识,已实测确认
  · tenantId 非 corpId,补充 tenantType 维度说明
- todo task list: 新增 Long 说明
  · 覆盖范围: 仅返回执行者维度待办,不含仅参与/仅创建的待办
  · 仅限个人待办,不含 OA 审批流/Teambition 项目任务
  · 自动分页说明
- todo task create: --executors flag 描述增强
  · 明确此处 userId 是通讯录 userId 并提示查询方式

Fixes #105

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-05-04 09:26:12 +08:00
ybc❤️zyrand猷诺 5258959a14 docs: update contact search examples from --keyword to --query (#209)
The --keyword flag was renamed to --query for contact user search,
contact dept search, and devdoc article search (as noted in CHANGELOG).
This commit updates all documentation references for the contact
commands. devdoc will be addressed in a follow-up PR.

Refs #105

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-04-30 16:32:34 +08:00
xianfeng wangandgithub-actions[bot] c515fc1001 1.0.19 CHANGELOG (#204)
* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* 1.0.19 changelog

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-30 14:36:58 +08:00
xianfeng wangandgithub-actions[bot] 7692048cf4 fix(discovery): surface edition supplement/fallback servers in runtime catalog loader (#197)
* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* fix conference discovery

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-30 09:44:25 +08:00
ybc❤️zyrand猷诺 426810a776 fix(todo): correct tool name from query_todo_detail to get_todo_detail (#202)
The todo task get command was calling query_todo_detail which returns
empty results. Changed to get_todo_detail to match the tool name
defined in discovery.json, restoring correct behavior.

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-04-29 22:59:37 +08:00
xuanandshangguanxuan.sgx f253841cf7 docs(changelog): note PAT core changes (#193)
* docs(changelog): note PAT core changes

* docs(changelog): rewrite v1.0.18 release notes

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-04-28 23:00:48 +08:00
xuanandshangguanxuan.sgx da2522c8ca feat(pat)!: A-core loop for host-owned PAT (#142)
* feat(pat): A-core minimum viable loop for host-owned PAT

Enables third-party agents to integrate PAT via the smallest possible
contract surface. The flow a host needs to ship end-to-end:

  1. Export DINGTALK_DWS_AGENTCODE in the spawned shell.
  2. Run any `dws ...` business command.
  3. On PAT hit, CLI exits with code 4 and writes a single-line stderr
     JSON following docs/pat/contract.md §2.
  4. Host parses the JSON and invokes `dws pat chmod <scope>...
     --agentCode ... --grant-type ...` to grant.
  5. Host replays the original command.

This PR intentionally ships ONLY the chmod path plus the host-owned
switch; the async flow (apply / status / scopes + authRequestId
registry + PAT_SCOPE_AUTH_REQUIRED active-request branch) lands in a
follow-up stacked PR (A-ext).

Scope of A-core:
- Host-owned PAT trigger: auth.HostOwnsPATFlow() keyed exclusively on
  DINGTALK_DWS_AGENTCODE (DINGTALK_AGENT / DWS_CHANNEL / claw-type do
  NOT participate).
- claw-type: hard-wired to "openClaw" in pkg/edition/default.go
  MergeHeaders hook, matching historical main behavior and decoupled
  from DINGTALK_AGENT.
- dws pat chmod: factory-built (newChmodCommand) so the PAT subcommand
  tree has no shared package-level caller.
- stderr JSON classifier: internal/errors/pat.go covers PAT_NO_PERMISSION
  / PAT_LOW_RISK / PAT_MEDIUM_RISK / PAT_HIGH_RISK / PAT_SCOPE_*
  selectors and fills data.hostControl for host consumption.
- Env contract: DINGTALK_SESSION_ID / DINGTALK_TRACE_ID /
  DINGTALK_MESSAGE_ID carry HTTP trace headers (Chain B);
  DWS_SESSION_ID (+ REWIND_SESSION_ID alias) is the lone fallback for
  `dws pat chmod --session-id` (Chain A). The two chains are
  independent; aliases do not cross-pollinate.
- Docs: docs/pat/{README,contract,host-integration}.md + refreshed
  docs/architecture.md "PAT Architecture" chapter + docs/reference.md
  PAT section. error-catalog.md folded into contract.md §6.
- Tests: host-owned signal, stderr contract, classifier, chmod factory,
  retry / poll loop for the CLI-owned chmod path.

Compatibility: no breaking changes on main. All non-PAT commands are
untouched.

Follow-up PRs (in order):
- codex/pat-ext  : dws pat apply / status / scopes + AsyncRegistry.
- codex/pat-oss-refactor (aka PR B) : oauth_helpers / secure_store
  refactors split out per code review.

Made-with: Cursor

* fix(pat): unify host-owned stderr contract

* revert: drop PAT docs and changelog from pat core PR

* fix(pat): pin behavior auth endpoint

* fix(pat): recognize legacy tool miss

* fix(pat): fallback on gateway diagnostics

* fix(pat): treat authorization uri as opaque

* fix(pat): accept result envelope in device-flow polling

* feat(pat): add browser policy and poll compatibility

* fix(pat): keep CLI and host PAT contracts separate

* test(pat): isolate opaque uri retry env

* fix(pat): harden PAT result routing

* fix(pat): harden host-owned flow and runtime fallback

* fix(pat): preserve chmod failure on empty grant result

* refactor(pat): drop dead doc anchors, dedupe stderr injection helpers

Address two reviewer findings on top of the PAT-core series.

Fix:
- chmod: resolveSessionIDFromEnv silently selects DWS_SESSION_ID; drop
  the slog.Warn that emitted both raw session ids into stderr /
  ~/.dws/logs.
- Remove every docs/pat/contract.md / error-catalog.md /
  host-integration.md anchor from comments, help, and tests; those
  files were never added by this branch. Comments are now self-contained
  or point to docs/reference.md where applicable.

Refactor:
- internal/errors/pat.go: extract lookupCodeIn so getPATErrorCode and
  getDWSGatewayErrorCode share one traversal; have ClassifyPatAuthCheck
  delegate to getPATErrorCode instead of repeating the enum walk.
- internal/errors/pat.go + internal/app/pat_auth_retry.go: extract
  ApplyHostMutations as the single injection point for data.hostControl
  + data.openBrowser; cleanPATJSON and enrichPATErrorForHostControl now
  share it so the two stderr-JSON write paths cannot drift.
- internal/app/pat_auth_retry.go: drop buildPATScopeHostJSON and
  buildHostControlState dead 1-line wrappers.
- internal/pat/chmod.go: drop unused patApply/Status/Scopes constants;
  derive legacyToolArgs from toolArgs by clone + scopes->scope rename so
  the two payloads stay in lock-step on every other field.

Net 15 files, +176 / -193. Verification:

  env -u DINGTALK_DWS_AGENTCODE go test \
    ./internal/errors ./internal/auth ./internal/pat ./test/unit
  env -u DINGTALK_DWS_AGENTCODE go test ./internal/app \
    -run 'Test(IsPat|ExtractPat|PrintPat|PollPat|HandlePat|RetryWithPat|EnrichPAT|BuildPAT|DirectRuntime|ResolveIdentityHeaders)'
  env -u DINGTALK_DWS_AGENTCODE go test -race \
    ./internal/pat ./internal/errors ./test/unit
  env -u DINGTALK_DWS_AGENTCODE go test -race ./internal/app \
    -run 'Test(HandlePat|RetryWithPat|EnrichPAT|BuildPAT|ResolveIdentityHeaders|DirectRuntime)'
  go vet ./... && go build ./...

All green.

Made-with: Cursor

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-04-28 21:17:23 +08:00
43715e28b0 docs: fix skill reference docs to align with actual CLI commands (#186)
* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* docs: fix skill reference docs to align with actual CLI commands

- simple.md: fix OA command names (pending→list-pending, initiated→list-initiated)
- simple.md: remove non-existent devdoc article search-error command
- simple.md: update intent guide and context table to use correct command names
- workbench.md: mark as Draft since workbench commands are not yet available in CLI runtime

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: wxianfeng <wang.fl1429@gmail.com>
Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-04-28 11:18:30 +08:00
修雨 d11aa0f030 fix(chat,cmdutil): restore group @-mentions and explicit members list subcommand (#180)
* fix(chat,cmdutil): restore group @-mentions and members list subcommand

Two CLI regressions surfaced after PR #170 promoted hardcoded helpers over
the discovery envelope:

1. issue #177 — `dws chat message send --group ... --at-users ...` failed
   with `unknown flag: --at-users` on v1.0.16. The hardcoded helper that
   replaced the v1.0.15 envelope leaf only declared --group / --user /
   --open-dingtalk-id / --text / --title and silently dropped the envelope's
   --at-users / --at-all / --at-mobiles flags, so any group @-mention call
   bounced at cobra's flag parser before ever reaching the MCP tool.

   Add the three flags back to `newChatMessageSendCommand` and forward
   atUserIds / isAtAll / atMobiles to `send_message_as_user` in the
   `--group` branch; reject the flags loudly outside `--group` to avoid
   silently swallowing user intent in single-chat mode (single-chat tools
   have no @-mention semantics).

2. issue #164 — `dws chat group members --id <openConversationId>` returned
   `unknown flag: --id`. The helper had `members` as a "group with bare
   RunE + --id/--cursor flags" while the envelope publishes `members` as a
   leaf for `get_group_members`; the merge layer's shape-mismatch branch
   treats envelope as authority and silently drops the entire helper
   subtree (list / add / remove / add-bot all become unreachable).

   Restructure helper-side `members` into a pure group container and
   promote list to its own explicit subcommand:

     dws chat group members list --id <openconversation_id>

   And extend `pkg/cmdutil/MergeHardcodedLeaves` so the existing
   OverridePriority annotation also applies to the leaf↔group shape
   mismatch — when the helper group carries strictly higher priority it
   replaces the dynamic leaf, mirroring the leaf↔leaf override semantics
   that PR #170 already established. Without this the helper's `list`
   subcommand stays unreachable and the user-visible bug persists.

Tests:

- `internal/helpers/chat_test.go`: forwards-at-mentions (group + each
  flag variant), rejects-at-mentions-outside-group (single-chat
  validation), members-list-subcommand (structure + execution).
- `internal/app/legacy_test.go`: pickCommands integration test that
  reproduces the #164 shape mismatch end-to-end and asserts the helper
  subtree survives.
- `pkg/cmdutil/leaf_merge_test.go`: positive + boundary (equal priority)
  cases for the new leaf↔group promotion path.

Verified end-to-end against existing chat helper tests, app pickCommands
tests, cmdutil merge tests, cobracmd priority tests, compat dynamic
command tests — all green.

Skill docs (`skills/references/products/chat.md`) updated to teach the
new `members list` path with a migration note, and to surface the
restored `--at-mobiles` flag alongside `--at-all` / `--at-users`.

Closes #177
Closes #164

* fix(chat): resolve merge conflict in command Long text

Combine --title-required wording from #174 with at-mention
documentation introduced in this PR. CI build was failing on
unresolved <<<<<<< / >>>>>>> markers left in chat.go.
2026-04-27 22:56:46 +08:00
fantiu 41396426d0 Merge pull request #184 from fantiu/feat-rawapi
feat(api): add raw DingTalk OpenAPI command with dual-version token support
2026-04-27 21:14:15 +08:00
fantiu 6946929e0b feat(api): add raw DingTalk OpenAPI command with dual-version token support 2026-04-27 20:54:57 +08:00
xuanandshangguanxuan.sgx 6756ead38a fix: preserve PAT authorization uri and accept result poll envelope (#182)
* fix(pat): treat authorization uri as opaque

* fix(pat): accept result envelope in device-flow polling

* docs(pat): clarify empty-uri guard intent

* fix(pat): guard poll debug output and lock envelope priority

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-04-27 20:04:01 +08:00
fantiu 4c44a2b632 Merge branch 'main' into feat-rawapi 2026-04-27 20:03:40 +08:00
修雨 c0468754a4 docs(changelog,readme): backfill v1.0.17 with mail product surface (#181)
CHANGELOG v1.0.17:
- Mail (#167) is a runtime product, not just a skill doc — verified by
  building v1.0.17 from source and inspecting `dws --help`. Lists the
  four leaf commands (mailbox list, message search/get/send) and the
  KQL pagination support.
- Plugin overlay-first registration (#179), VisibleProducts union
  refactor (#179), buildStdioCommands shared-helper refactor (#179),
  cache-poisoning guard (#179), --title docs clarification (#174).

README / README_zh:
- Add Mail row to Key Services table (4 commands, mailbox/message
  subgroups).
- Bump total: 159 commands across 13 products → 163 commands across
  14 products.
- Remove `mail` from "Coming soon" (now shipped).
- Drop the hardcoded "159" count from the command-index callout to
  avoid future skew until command-index.md is regenerated.
2026-04-27 17:21:45 +08:00
fantiu e06b1d68ff feat: raw API update README. 2026-04-27 17:08:32 +08:00
修雨 b101d5b12b docs(skills,chat): clarify --title is required for chat message send (#174)
The backend MCP tools `send_direct_message_as_user` and
`send_message_as_user` both require `title` as a required field.
However, the skill documentation and --help Long text described
--title as "optional", causing AI models to omit it and trigger
a vague `business error: success=false`.

Update descriptions in:
- skills/references/products/chat.md
- internal/helpers/chat.go (command Long text)

Closes #173
2026-04-27 16:49:05 +08:00
xianfeng wangandgithub-actions[bot] acc5dc8a3c feat(plugins): register stdio plugin commands from CLI overlay before discovery (#179)
* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* plugin in help

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-27 16:20:35 +08:00
fantiu 592749c986 feat: The token retrieval interface is now unified to one. 2026-04-27 15:11:12 +08:00
fantiu 9b6174e03e feat(api): add raw DingTalk OpenAPI command with dual-version token support 2026-04-27 10:29:34 +08:00
github-actions[bot] f73fa08e49 chore: update coverage badge [skip ci] 2026-04-27 01:49:47 +00:00
fantiu bb5e5a40d9 Merge branch 'DingTalk-Real-AI:main' into main 2026-04-27 09:47:46 +08:00
ybc❤️zyrand猷诺 e3b0c9b1ed feat: add mail product reference doc and register in SKILL.md (#167)
- Add skills/references/products/mail.md with 4 subcommands:
  mailbox list, message search (KQL), message get, message send
- Register mail in SKILL.md: product table, intent routing, description
- All params verified against dws schema and --mock execution

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-04-27 09:35:49 +08:00
修雨 cc1f0a47e3 docs(changelog): backfill v1.0.16 (and v1.0.14 docs-only re-tag) (#171)
* docs(changelog): backfill v1.0.16 (and v1.0.14 docs-only re-tag)

v1.0.16 ships discovery service abstraction + schema v3 (#156),
open-edition helper grafting (#169), chat message send destination
routing (#170), and device flow defensive credential reset (#157).
The v1.0.16 GitHub release notes are refreshed in lock-step with
this CHANGELOG entry (previously they were the auto-generated
4-line commit list).

v1.0.14 also gets a one-line entry noting it is a docs-only re-tag
of v1.0.13 (#153 backfilled v1.0.13 release notes after the binary
was already published; no functional change).

* docs(changelog): reorder v1.0.14 to date-descending position

v1.0.14 (2026-04-22) was sitting above v1.0.15 (2026-04-23). Move it between v1.0.15 and v1.0.13 so the file reads strictly date-descending.

* docs(changelog): trim v1.0.16 entry to match prior granularity

Drop nested sub-bullets (discovery internals / schema v3 enumeration / device-flow root cause) and the standalone Tests section; keep one bullet per PR. Aligns with v1.0.4 / v1.0.7 / v1.0.8 single-paragraph + 3-5 bullets style. 47 lines → 7 net (-40).
2026-04-27 09:35:11 +08:00
fantiu d71031d097 feat(api): add raw DingTalk OpenAPI command 2026-04-24 17:14:10 +08:00
fantiu 46192d68ba feat(api):client logic fixed. 2026-04-24 17:04:09 +08:00
fantiu eeb1782f7a feat(api): add raw DingTalk OpenAPI command with dual-version token support 2026-04-24 16:40:13 +08:00
github-actions[bot] 4d0e194861 chore: update coverage badge [skip ci] 2026-04-24 08:12:55 +00:00
fantiu 3046ff8e77 Merge branch 'DingTalk-Real-AI:main' into main 2026-04-24 16:10:50 +08:00
fantiu a2908d2cbf Merge branch 'main' of github.com:fantiu/dingtalk-workspace-cli into feat-rawapi
feat:unified login system
2026-04-23 15:38:46 +08:00
fantiu 2f935586c8 Merge branch 'DingTalk-Real-AI:main' into main 2026-04-23 15:37:54 +08:00
github-actions[bot] 89c6b737c0 chore: update coverage badge [skip ci] 2026-04-22 11:00:58 +00:00
fantiu bfe0e310fe Merge branch 'DingTalk-Real-AI:main' into main 2026-04-22 18:59:04 +08:00
fantiu fa1ab411e3 Merge branch 'main' of github.com:fantiu/dingtalk-workspace-cli into feat-security-hardening
fix(auth): attribute CLI auth denial to channel before user scope
2026-04-22 10:53:55 +08:00
fantiu b247ef1266 Merge branch 'DingTalk-Real-AI:main' into main 2026-04-22 10:53:34 +08:00
fantiu c7ee0491b2 fix(auth): attribute CLI auth denial to channel before user scope 2026-04-22 10:39:03 +08:00
github-actions[bot] d06aa618e5 chore: update coverage badge [skip ci] 2026-04-21 02:11:48 +00:00
fantiu 9bedec7ed2 Merge branch 'DingTalk-Real-AI:main' into main 2026-04-21 10:09:47 +08:00
github-actions[bot] 0bc5345cf5 chore: update coverage badge [skip ci] 2026-04-20 02:24:45 +00:00
91 changed files with 8964 additions and 491 deletions
+1 -1
View File
@@ -1 +1 @@
<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>
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 54.2%"><title>coverage: 54.2%</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">54.2%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">54.2%</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+131
View File
@@ -4,6 +4,133 @@ 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.19] - 2026-04-30
Discovery hardening for edition overlays: `edition.SupplementServers` / `FallbackServers` hooks now consistently surface through the **runtime catalog loader**, not just the static command tree, so overlay products that live outside the Portal envelope (e.g. Wukong gray-release `conference`) resolve an endpoint on both the cold-cache and tool-not-in-catalog paths. Ships with per-edition cache partitioning to stop cross-edition disk-cache leakage, plus a small todo fix.
### Added
- **`pkg/config.EditionPartition(name)`** (#197) — returns the cache partition key for a given edition. Open-source core (`""` / `"open"`) keeps using `DefaultPartition` (`default/default`); every other edition gets its own namespace (`<edition>/default`), preventing cross-edition data leakage in the shared `~/.dws` disk cache. Lives in `pkg/config` as a leaf helper so `internal/cli`, `internal/app`, and `internal/cache` can all call it without risking import cycles.
- **`internal/editionmerge` shared package** (#197) — single source of truth for converting `edition.ServerInfo` into `market.ServerDescriptor` (`ToDescriptor`) and for merging `SupplementServers` / `FallbackServers` into a descriptor list. Both `internal/cli` (command tree) and `internal/app` (runtime catalog) now apply the edition hooks against the same discovery pipeline.
### Changed
- **`EnvironmentLoader.loadFromCache` honors `SupplementServers` even on empty registry** (#197) — when the Portal registry cache is missing or empty, the catalog loader still materialises the edition's `SupplementServers` as endpoint-only `discovery.RuntimeServer` entries (source: `edition_supplement`), so hardcoded overlay commands for supplement-only products can still resolve an endpoint via the catalog path. Previously `loadFromCache` short-circuited to an empty catalog whenever the registry snapshot was empty, silently dropping gray-release products.
- **Cache loader switches from `DefaultPartition` to `EditionPartition(edition.Get().Name)`** (#197) — the runtime catalog, registry snapshot, and tools snapshot are now partitioned per edition instead of all editions sharing `default/default`.
- **`loadFromCache` appends supplement servers alongside fresh-cache servers** (#197) — supplement entries whose `CLI.ID` / `Key` are already present in the cached registry are skipped, so the hook never shadows Portal-published servers; only new products are added.
- **`runtimeRunner.Run` falls through to `directRuntimeEndpoint` for supplement products** (#197) — when the catalog contains the product (e.g. supplied by `SupplementServers`) but the specific tool is not declared, the runner now trusts `directRuntimeEndpoint` to resolve a working endpoint for the tool before returning the explicit catalog-miss error. Supplement entries intentionally carry no tool list, so this is the path that makes overlay-only tools executable.
- **Legacy `mergeSupplementServers` / `fallbackToDescriptors` moved out of `internal/app/legacy.go`** (#197) — relocated into `internal/editionmerge` and reused by the catalog loader, eliminating the duplicate `edition.ServerInfo → market.ServerDescriptor` logic that previously only ran on the static command-tree path.
### Fixed
- **`dws todo task get` returns empty** (#202) — the helper was calling `query_todo_detail`, which is not a valid MCP tool and returns empty. Switched to `get_todo_detail` as declared in `discovery.json`, restoring correct task-detail behaviour.
- **Conference and other Wukong gray-release products miss endpoint on cold cache** (#197) — products registered only via `edition.SupplementServers` (not yet in the Portal envelope) now resolve an endpoint through the catalog path in both cold-start and tool-not-declared scenarios.
### Tests
- `internal/editionmerge/merge_test.go` — descriptor conversion + supplement/fallback merge semantics.
- `internal/cli/loader_partition_test.go` + `loader_supplement_test.go` — edition-partitioned cache reads and supplement hook surfacing from `loadFromCache` (including empty-registry cold path and existing-ID deduplication).
- `internal/app/legacy_wukong_partition_e2e_test.go` — end-to-end cache partition isolation for the Wukong edition.
- `internal/app/runner_supplement_fallback_test.go` — runner falls through to `directRuntimeEndpoint` when the tool isn't declared by a supplement-sourced catalog entry.
- `pkg/config/constants_test.go` — `EditionPartition` name handling (`""`, `"open"`, custom edition).
### Docs
- **CHANGELOG v1.0.18 rewrite** (#193) — previous release notes expanded to call out the PAT host-owned A-core flow, exit-code contract change (auth `4`, Discovery/cache/protocol `6`), `dws pat chmod` / `pat browser-policy` entry points, stderr-JSON classifier updates, and host-control metadata injection.
## [1.0.18] - 2026-04-28
Raw DingTalk OpenAPI access lands as a new `dws api` surface for both `api.dingtalk.com` and `oapi.dingtalk.com`, backed by app-level token caching and guarded host allowlists. PAT enters the host-owned **A-core** loop: agent hosts can own authorization UI through `DINGTALK_DWS_AGENTCODE`, parse single-line stderr JSON, call `dws pat chmod`, and replay the original command. Chat helper regressions are fixed, skill references are brought back in line with shipped commands, and the v1.0.17 Mail release notes are backfilled into README / CHANGELOG.
### Breaking
- **PAT exit-code contract** (#142) — PAT authorization interceptions now use exit code `4`; Discovery, cache, and protocol negotiation failures now use exit code `6`. Downstream scripts that previously treated `4` as Discovery must update their handling.
### Added
- **`dws api` raw DingTalk OpenAPI command** (#184) — direct DingTalk OpenAPI calls without writing an MCP wrapper first. Supports `GET` / `POST` / `PUT` / `PATCH` / `DELETE`, JSON `--params` / `--data`, stdin input, dry-run previews, `--jq`, field selection, `--page-all`, `--page-limit`, `--page-delay`, and `--base-url`.
- **Dual-form OpenAPI routing** (#184) — `api.dingtalk.com` requests use the `x-acs-dingtalk-access-token` header; `oapi.dingtalk.com` requests use the legacy `access_token` query parameter. The raw API client validates the target host before attaching credentials.
- **App-level token cache for raw API** (#184) — custom-app credentials now fetch app access tokens from the unified OAuth endpoint, cache them while valid, and refresh them before expiry. The same token provider works for new-style and legacy OpenAPI calls.
- **Host-owned PAT A-core flow** (#142) — when `DINGTALK_DWS_AGENTCODE` is set, PAT hits return `exit=4` plus single-line stderr JSON; the host renders authorization UI, calls `dws pat chmod <scope>...`, and replays the original command.
- **`dws pat chmod` authorization entry point** (#142) — grants scopes with `--agentCode`, `--grant-type`, and session fallback support; `DINGTALK_DWS_AGENTCODE` can supply the agent code when the flag is omitted.
- **PAT browser-open policy** (#142) — `dws pat browser-policy --enabled <true|false> [--agentCode <id>]` controls whether the CLI may open a browser, independently from `--format` output mode.
### Changed
- **README raw API guide** (#184) — English and Chinese READMEs now document custom-app prerequisites, api/oapi examples, auto-pagination, dry-run, jq filtering, security properties, and the new Raw API service-table row.
- **Raw API token retrieval path** (#184) — token lookup now goes through a single app-token interface; stale auth-refresh retry helpers were removed from the raw API path.
- **PAT stderr JSON classifier** (#142) — recognizes `code`, `errorCode`, and `error_code`, including `PAT_NO_PERMISSION`, risk-tier PAT errors, `PAT_SCOPE_AUTH_REQUIRED`, and `AGENT_CODE_NOT_EXISTS`.
- **Host-control metadata injection** (#142) — classifier and active-retry paths now share one mutation point for `data.hostControl` and `data.openBrowser`, keeping host-facing JSON shapes aligned.
- **Open-edition routing signals** (#142) — open edition pins `claw-type: openClaw`; `DINGTALK_AGENT`, `DWS_CHANNEL`, and host-owned PAT detection are kept as independent signals.
- **Behavior authorization endpoint fallback** (#142) — the PAT runtime can resolve the built-in behavior-authorization MCP endpoint before discovery data is available.
- **v1.0.17 documentation backfill** (#181) — the previous release notes and README service table now explicitly include the shipped Mail product, update the total to **163 commands across 14 products**, and remove Mail from "Coming soon".
### Fixed
- **CLI auth-denial attribution** — local CLI authorization denials are attributed to the channel before falling back to user-scope classification, avoiding user-scope misclassification for channel-level auth failures.
- **Opaque authorization URLs** (#182, #142) — PAT authorization links are preserved verbatim, including query/hash/fragment content required by the server.
- **Polling compatibility** (#182, #142) — device-flow result envelopes and no-`flowId` device-code fallback remain supported, with guarded debug output and envelope priority.
- **Group chat @-mentions restored** (#180) — `dws chat message send --group ...` again accepts and forwards `--at-users`, `--at-all`, and `--at-mobiles`; those flags are rejected outside group-chat mode so single-chat sends cannot silently drop @-mention intent.
- **Explicit members-list command restored** (#180) — `dws chat group members list --id <openConversationId>` is reachable after the helper/dynamic merge path changed. `cmdutil.MergeHardcodedLeaves` now honors higher-priority helper groups when a dynamic envelope contributes a leaf at the same path.
- **Skill reference command names** (#186) — `simple.md` now uses shipped OA command names (`list-pending`, `list-initiated`), removes a non-existent devdoc `search-error` command, and marks `workbench.md` as Draft because workbench commands are not available in the runtime.
- **Empty grant result handling** (#142) — `dws pat chmod` now returns an explicit error instead of treating `{"Content": null}` as success.
- **Session-id log safety** (#142) — raw `DWS_SESSION_ID` / `REWIND_SESSION_ID` values are no longer logged when the two env vars disagree.
### Tests
- Added raw API coverage for request validation, api/oapi routing, token management, pagination, response handling, dry-run output, JSON parsing, stdin handling, and command wiring. (#184)
- Added chat/cmdutil regression tests for group @-mention forwarding, single-chat rejection, `members list`, helper-vs-envelope shape mismatch, and merge-priority behavior. (#180)
- Added PAT contract coverage for host-owned signal selection, single-line stderr JSON, chmod env fallback and legacy alias fallback, browser policy, direct-runtime PAT endpoint fallback, and retry/poll behavior. (#142)
- Coverage badge refreshed after the post-v1.0.17 CI runs.
## [1.0.17] - 2026-04-27
New **Mail** product surface (mailbox list, KQL message search, message get, send) brings runtime command count to **163 across 14 products**. Plugin command-tree visibility hardening: stdio plugins shipping CLI overlays no longer wait on subprocess discovery to surface their commands, and overlay-registered plugin products are no longer hidden by edition `VisibleProducts` whitelists. Chat docs clarify that `--title` is required on `dws chat message send`.
### Added
- **`mail` product** (#167) — new top-level service for DingTalk Mail. Four leaf commands across two subgroups:
- `dws mail mailbox list` — list mailbox addresses available to the current user (`list_user_mailboxes`)
- `dws mail message search` — KQL search across folders / sender / date / attachments / read-state (`search_emails`); supports `--cursor` pagination
- `dws mail message get` — fetch full message body + headers + attachments by message ID (`get_email_by_message_id`)
- `dws mail message send` — send email to one or more recipients (`send_email`)
- Skill reference at `skills/references/products/mail.md` registered in `skills/SKILL.md` master index and intent decision tree
- **Stdio plugin overlay-first command registration** (#179) — when a stdio plugin's `overlay.json` declares `toolOverrides`, command trees are built from manifest metadata synchronously at startup, no subprocess `Initialize` / `tools/list` handshake required. Previously, slow or failing subprocesses left plugin commands invisible in `dws --help`. Background discovery still runs to refresh the warm cache for richer flag types on subsequent startups.
### Changed
- **`hideNonDirectRuntimeCommands` / `visibleMCPRootCommands` / `visibleUtilityRootCommands`** (#179) — refactored to share a single `resolveVisibleProducts()` helper that **unions** the edition's `VisibleProducts` hook with `DirectRuntimeProductIDs()`, so plugins registered via `AppendDynamicServer` stay visible in `dws --help` even when an edition installs a static product whitelist. Previously the hook fully replaced the dynamic registry, silently hiding plugin commands.
- **`dws chat message send` documentation clarifies `--title` is required** (#174) — the helper command short text and the chat skill reference now state explicitly that `--title` is mandatory for both group and single-chat sends, matching the runtime validation.
- **`buildStdioCommands` refactored to share helpers with the overlay-first path** (#179) — overlay parsing (`resolveStdioOverlay`) and tools→DetailTool conversion (`toolsToDetails`) extracted as package-level helpers; the legacy discovery-first stdio path now delegates to them, eliminating duplicated overlay JSON / cache-snapshot logic.
### Fixed
- **Negative-cache poisoning guard for stdio plugin discovery** (#179) — `refreshStdioToolsCache` now skips `SaveTools` entirely when discovery returns an empty tool list (transient failure, subprocess not ready, RPC timeout), so a single bad refresh cannot overwrite a previously-good cache and degrade flag enrichment on the next startup.
### Tests
- 6 new test cases in `internal/app/plugin_stdio_overlay_test.go` and `internal/app/visibility_test.go` cover overlay-first registration without discovery, warm-cache flag enrichment from `InputSchema`, fallback when overlays lack `toolOverrides`, the cache-poisoning guard, and integration cases for plugin visibility under restrictive `VisibleProducts` whitelists.
- Coverage 49.8% → 52.8%.
## [1.0.16] - 2026-04-24
Discovery service abstraction with schema v3 extensions, open-edition helper-subtree restoration, and a defensive device-flow login reset.
### Added
- **`internal/discovery` service abstraction** (#156) — encapsulates market registry fetch, MCP runtime negotiation (`initialize → tools/list → detail` merge), and multi-level cache fallback. `EnvironmentLoader` now does cache-first startup, with degraded-mode reasons (`unauthenticated` / `market_unreachable` / `runtime_all_failed`) and `UpdatedAt`-based selective re-discovery.
- **Schema v3 extensions** (#156) — positional parameters with typed coercion, `Example` on `--help`, flag `Default` / `RuntimeDefault` (with `$currentUserId` / `$now` etc.), `BodyWrapper`, `MutuallyExclusive` / `RequireOneOf` flag groups, `OmitWhen`, explicit `Type` override, and detail-schema `default` propagation.
- **`dws chat message send` destination-flag routing** (#170) — open edition gains a hardcoded helper that dispatches by `--group` (→ `send_message_as_user`) vs `--user` / `--open-dingtalk-id` (→ `send_direct_message_as_user`), mirroring the closed-source overlay so single-chat sends finally work end-to-end.
### Changed
- **`pickCommands` → `cmdutil.MergeHardcodedLeaves`** (#169) — when a top-level product name collides between the dynamic overlay and a helper subtree, helper-only siblings are grafted into the dynamic tree instead of dropped. Restores `dws chat message send-by-bot` / `recall-by-bot` / `send-by-webhook` and `dws chat group members add-bot`, which had silently vanished from the open edition.
- **`OverridePriority` / `MergeHardcodedLeaves` promoted into `pkg/cmdutil`** (#170) — single source of truth for the merge layer; hardcoded leaves can opt into overriding the dynamic envelope via a strictly higher priority.
### Fixed
- **Device flow defensively resets credentials before login** (#157) — `--device` login now clears stale credential state and re-fetches `clientID` from the MCP server, regardless of what previous login methods (OAuth scan, PAT) left in `app.json`. Fixes the case where a prior OAuth login made `--device` fall back to direct mode and demand `clientSecret`.
## [1.0.15] - 2026-04-23
Compat layer gains **subcommand merging** under shared parents so multiple server entries can contribute into the same `dws <parent> <branch>` subtree without producing duplicate `--help` rows. Ships with a fresh auto-generated command index doc, a README sync to **159 commands across 13 products**, and a wide-ranging flag-naming cleanup that standardises CLI flags across chat, calendar, drive, minutes, contact, and devdoc commands.
@@ -46,6 +173,10 @@ Compat layer gains **subcommand merging** under shared parents so multiple serve
- `TestBuildDynamicCommands_ParentMergeRecursive` — recursive merge through nested groups (e.g. `chat.group.members`)
- `TestBuildDynamicCommands_ParentMergeLeafCollision` — identical leaf paths resolve first-wins without producing duplicates
## [1.0.14] - 2026-04-22
Docs-only re-tag of v1.0.13. The single commit (#153) backfills the v1.0.13 release notes after the binary was already published; no functional or CLI surface change.
## [1.0.13] - 2026-04-22
IM / Messaging capability expansion: the `chat` (aka `im`) product surface grows from "group + bot messaging" into a full conversational layer — user-identity messaging, message reading & search, personal messages, topic replies, mentions, focused contacts, unread/top/common conversations, org-wide group creation, and first-class bot lifecycle.
+54 -6
View File
@@ -194,7 +194,7 @@ dws todo task create --title "Quarterly report" --executors "<your-userId>" #
dws todo task list --dry-run # preview without executing
```
> **Full command list**: [`docs/command-index.md`](./docs/command-index.md) — all 159 commands with descriptions and when-to-use guidance.
> **Full command list**: [`docs/command-index.md`](./docs/command-index.md) — all commands with descriptions and when-to-use guidance.
## Using with Agents
@@ -207,7 +207,7 @@ dws is designed as an AI-native CLI. Complete [Installation](#installation) and
dws todo task create --title "Review PR" --executors "<your-userId>" --yes
# Use --dry-run to preview operations (safe execution)
dws contact user search --keyword "engineering" --dry-run
dws contact user search --query "engineering" --dry-run
# Use --jq to extract precisely (save tokens)
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
@@ -276,6 +276,52 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
## Features
<details>
<summary><strong>Raw API Access</strong> — call any DingTalk OpenAPI directly</summary>
`dws api` lets you call any DingTalk OpenAPI without an SDK. Tokens are automatically acquired and refreshed.
> **Prerequisite**: Must login with your own app credentials (see [Custom App mode](#getting-started)). Encrypted tokens from MCP default-credential login are not supported for raw API calls.
```bash
# Login (first time only)
dws auth login --client-id <APP_KEY> --client-secret <APP_SECRET>
# === api.dingtalk.com ===
# List all enterprise apps
dws api GET /v1.0/microApp/allApps
# Search users (POST + JSON body)
dws api POST /v1.0/contact/users/search \
--data '{"queryWord":"engineering","offset":0,"size":10}'
# === oapi.dingtalk.com ===
# Get user details (use --base-url to specify domain)
dws api POST /topapi/v2/user/get \
--base-url https://oapi.dingtalk.com \
--data '{"userid":"<USER_ID>"}'
# Or use the full URL directly
dws api POST https://oapi.dingtalk.com/topapi/v2/user/get \
--data '{"userid":"<USER_ID>"}'
# === General ===
dws api GET /v1.0/microApp/allApps --page-all # auto-paginate
dws api GET /v1.0/microApp/allApps --dry-run # preview request
dws api GET /v1.0/microApp/allApps --jq '.agentId' # jq filtering
```
| Feature | Details |
|---------|----------|
| Dual-form auto-detection | Automatically selects api.dingtalk.com (header auth) or oapi.dingtalk.com (query-param auth) based on URL |
| Automatic token management | App-level accessToken is fetched on first call, cached while valid, auto-refreshed on expiry |
| Domain allowlist | Only `api.dingtalk.com` and `oapi.dingtalk.com` permitted — prevents token leakage |
| Auto-pagination | `--page-all` iterates all pages. `--page-limit` caps the maximum (default 10, set to 0 for unlimited, hard cap at 500 to prevent infinite loops) |
</details>
<details>
<summary><strong>Smart Input Correction</strong> — auto-corrects common AI model parameter mistakes</summary>
@@ -286,7 +332,7 @@ Built-in pipeline engine that normalizes flag names, splits sticky arguments, an
dws aitable record query --baseId BASE_ID --tableId TABLE_ID # auto-corrected to --base-id --table-id
# Sticky argument splitting
dws contact user search --keyword "engineering" --timeout30 # auto-split to --timeout 30
dws contact user search --query "engineering" --timeout30 # auto-split to --timeout 30
# Fuzzy flag name matching
dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-id -> --table-id
@@ -366,16 +412,18 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| Doc | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), upload / download |
| Drive | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | DingTalk drive file ops: list, info, download, create folders, two-phase upload |
| Minutes | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | List AI meeting notes (mine / shared), details (info / summary / keywords / transcription / todos / batch), title/summary updates, mind map, speaker replace, hot-word, upload session |
| Mail | `mail` | 4 | `mailbox` `message` | List mailbox addresses, KQL message search, get full message content, send email |
| DevDoc | `devdoc` | 1 | `article` | Search the DingTalk Open Platform documentation |
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
> **159 commands across 13 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
> **163 commands across 14 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
<details>
<summary>Coming soon</summary>
`mail` (email) · `conference` (video) · `aiapp` (AI apps) · `live` (streaming) · `wiki` (knowledge base)
`conference` (video) · `aiapp` (AI apps) · `live` (streaming) · `wiki` (knowledge base)
</details>
@@ -424,7 +472,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
## Reference & Docs
- [Command Index](./docs/command-index.md) — every runtime command (159 total) with description and when-to-use guidance
- [Command Index](./docs/command-index.md) — every runtime command with description and when-to-use guidance
- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
- [Architecture](./docs/architecture.md) — discovery-driven pipeline, IR, transport layer
- [Changelog](./CHANGELOG.md) — release history and migration notes
+54 -6
View File
@@ -194,7 +194,7 @@ dws todo task create --title "季度汇报" --executors "<your-userId>" # 创
dws todo task list --dry-run # 预览操作但不执行
```
> **完整命令列表**:[`docs/command-index.md`](./docs/command-index.md) — 全部 159 条命令,带描述和使用场景。
> **完整命令列表**:[`docs/command-index.md`](./docs/command-index.md) — 全部命令,带描述和使用场景。
## 在 Agent 中使用
@@ -207,7 +207,7 @@ dws 是为 AI Agent 设计的 CLI 工具。请先完成[安装](#安装)和[开
dws todo task create --title "Review PR" --executors "<your-userId>" --yes
# 使用 --dry-run 预览操作(安全执行)
dws contact user search --keyword "张三" --dry-run
dws contact user search --query "张三" --dry-run
# 使用 --jq 精确提取(节省 token)
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
@@ -276,6 +276,52 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
## 功能特性
<details>
<summary><strong>Raw API 调用</strong> — 直接调用钉钉 OpenAPI</summary>
`dws api` 让你直接调用任意钉钉 OpenAPI,无需 SDK,Token 自动获取和刷新。
> **前置条件**:必须使用自有应用凭证登录(见[自建应用模式](#开始使用))。通过 MCP 默认凭证登录 不支持 raw API 调用。
```bash
# 登录(仅首次)
dws auth login --client-id <APP_KEY> --client-secret <APP_SECRET>
# === api.dingtalk.com ===
# 获取企业所有应用列表
dws api GET /v1.0/microApp/allApps
# 搜索用户 (POST + JSON body)
dws api POST /v1.0/contact/users/search \
--data '{"queryWord":"张三","offset":0,"size":10}'
# === oapi.dingtalk.com ===
# 获取用户详情(使用 --base-url 指定域名)
dws api POST /topapi/v2/user/get \
--base-url https://oapi.dingtalk.com \
--data '{"userid":"<USER_ID>"}'
# 也可以直接使用完整 URL
dws api POST https://oapi.dingtalk.com/topapi/v2/user/get \
--data '{"userid":"<USER_ID>"}'
# === 通用功能 ===
dws api GET /v1.0/microApp/allApps --page-all # 自动翻页
dws api GET /v1.0/microApp/allApps --dry-run # 预览请求
dws api GET /v1.0/microApp/allApps --jq '.agentId' # jq 过滤
```
| 特性 | 说明 |
|------|------|
| 双形态自动识别 | 根据 URL 自动选择 api.dingtalk.com(Header 认证)或 oapi.dingtalk.com(Query 参数认证) |
| Token 自动管理 | 首次调用自动获取应用级 accessToken,有效期内缓存,过期自动刷新 |
| 域名白名单 | 仅允许 `api.dingtalk.com` 和 `oapi.dingtalk.com`,防止 Token 泄露 |
| 自动分页 | `--page-all` 自动遍历所有分页。`--page-limit` 控制翻页上限(默认 10,设为 0 不限制,硬上限 500 防止死循环) |
</details>
<details>
<summary><strong>智能输入纠错</strong> — 自动修正 AI 模型常见的参数错误</summary>
@@ -286,7 +332,7 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
dws aitable record query --baseId BASE_ID --tableId TABLE_ID # 自动纠正为 --base-id --table-id
# 粘连参数自动拆分
dws contact user search --keyword "张三" --timeout30 # 自动拆分为 --timeout 30
dws contact user search --query "张三" --timeout30 # 自动拆分为 --timeout 30
# 拼写错误模糊匹配
dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-id → --table-id
@@ -366,16 +412,18 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| 文档 | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | 搜索 / 读写文档、文件与文件夹创建、块级编辑、评论(list / create / reply / create-inline)、上传 / 下载 |
| 钉盘 | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | 钉盘文件操作:列表、详情、下载、创建文件夹、两阶段上传 |
| AI 听记 | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | 听记列表(我创建 / 共享给我)、详情(info / summary / keywords / transcription / todos / batch)、标题/摘要更新、思维导图、发言人替换、热词、上传会话 |
| 邮箱 | `mail` | 4 | `mailbox` `message` | 邮箱地址列表、KQL 邮件搜索、邮件详情、发送邮件 |
| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 |
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
> **13 个产品,159 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
> **14 个产品,163 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
<details>
<summary>即将推出</summary>
`mail`(邮箱)· `conference`(视频会议)· `aiapp`(AI 应用)· `live`(直播)· `wiki`(知识库)
`conference`(视频会议)· `aiapp`(AI 应用)· `live`(直播)· `wiki`(知识库)
</details>
@@ -426,7 +474,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
## 参考与文档
- [命令索引](./docs/command-index.md) — 159 条运行时命令,带描述与使用场景
- [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
- [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层
- [更新日志](./CHANGELOG.md) — 版本历史与迁移说明
+1
View File
@@ -53,6 +53,7 @@ __KEG_ONLY_LINE__
Pathname.new(File.join(Dir.home, ".kiro/skills/dws")),
Pathname.new(File.join(Dir.home, ".trae/skills/dws")),
Pathname.new(File.join(Dir.home, ".openclaw/skills/dws")),
Pathname.new(File.join(Dir.home, ".hermes/skills/dws")),
]
targets.each_with_index do |dest, index|
+1
View File
@@ -22,6 +22,7 @@ const AGENT_DIRS = [
".kiro/skills",
".trae/skills",
".openclaw/skills",
".hermes/skills",
];
const PLATFORM_MAP = {
+6 -5
View File
@@ -19,8 +19,9 @@
| 1 | API | MCP tool call or upstream API failure / MCP 工具调用或上游 API 失败 |
| 2 | Auth | Authentication or authorization failure / 身份认证或授权失败 |
| 3 | Validation | Invalid input, flags, or parameter schema mismatch / 输入参数校验失败 |
| 4 | Discovery | Server discovery, cache, or protocol negotiation failure / 服务发现失败 |
| 4 | PAT | PAT authorization interception; stderr carries raw machine-readable PAT JSON / PAT 授权拦截;stderr 返回原始机器可解析 JSON |
| 5 | Internal | Unexpected internal error / 未预期的内部错误 |
| 6 | Discovery | Server discovery, cache, or protocol negotiation failure / 服务发现、缓存或协议协商失败 |
With `-f json`, error responses include structured payloads: `category`, `reason`, `hint`, `actions`.
@@ -29,9 +30,9 @@ With `-f json`, error responses include structured payloads: `category`, `reason
## Output Formats / 输出格式
```bash
dws contact user search --keyword "Alice" -f table # Table (default, human-friendly / 表格,默认)
dws contact user search --keyword "Alice" -f json # JSON (for agents and piping / 适合 agent)
dws contact user search --keyword "Alice" -f raw # Raw API response / 原始响应
dws contact user search --query "Alice" -f table # Table (default, human-friendly / 表格,默认)
dws contact user search --query "Alice" -f json # JSON (for agents and piping / 适合 agent)
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
dws schema -f pretty ding.send_ding_message # Pretty (ANSI-colored, schema-aware / 彩色分区,专为 schema 设计)
```
@@ -44,7 +45,7 @@ dws todo task list --dry-run # Preview MCP call without executing / 预览但
## Output to File / 输出到文件
```bash
dws contact user search --keyword "Alice" -o result.json
dws contact user search --query "Alice" -o result.json
```
## Schema Introspection / Schema 查询
+226
View File
@@ -0,0 +1,226 @@
// 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 apiclient provides a lightweight HTTP client for calling DingTalk
// OpenAPI (https://api.dingtalk.com) directly, bypassing the MCP JSON-RPC
// transport. It is used exclusively by the `dws api` command.
package apiclient
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
const (
// DefaultBaseURL is the DingTalk new-style OpenAPI base URL.
DefaultBaseURL = "https://api.dingtalk.com"
// LegacyBaseURL is the DingTalk legacy (oapi) API base URL.
LegacyBaseURL = "https://oapi.dingtalk.com"
// AuthHeader is the new-style OpenAPI authentication header.
AuthHeader = "x-acs-dingtalk-access-token"
// LegacyAuthParam is the query parameter used for legacy API authentication.
LegacyAuthParam = "access_token"
)
// AllowedMethods is the set of HTTP methods permitted for raw API calls.
var AllowedMethods = map[string]bool{
"GET": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true,
}
// RawAPIRequest describes a raw API request to DingTalk OpenAPI.
type RawAPIRequest struct {
Method string // GET, POST, PUT, PATCH, DELETE
Path string // /v1.0/calendar/events or full URL
Params map[string]any // query parameters
Data any // request body (JSON), nil for GET
}
// RawAPIResponse encapsulates the raw HTTP response.
type RawAPIResponse struct {
StatusCode int
Header http.Header
Body []byte
}
// APIClient wraps an HTTP client for DingTalk OpenAPI calls.
type APIClient struct {
BaseURL string
HTTPClient *http.Client
Token string
}
// NewClient creates an APIClient with sensible defaults.
func NewClient(token, baseURL string) *APIClient {
if strings.TrimSpace(baseURL) == "" {
baseURL = DefaultBaseURL
}
return &APIClient{
BaseURL: strings.TrimRight(baseURL, "/"),
Token: token,
HTTPClient: &http.Client{
Transport: defaultTransport(),
Timeout: 30 * time.Second,
},
}
}
// Do sends a raw API request and returns the response.
func (c *APIClient) Do(ctx context.Context, req RawAPIRequest) (*RawAPIResponse, error) {
method := strings.ToUpper(strings.TrimSpace(req.Method))
if !AllowedMethods[method] {
return nil, fmt.Errorf("unsupported HTTP method: %s (allowed: GET, POST, PUT, PATCH, DELETE)", req.Method)
}
fullURL, err := c.buildURL(req.Path, req.Params)
if err != nil {
return nil, fmt.Errorf("building request URL: %w", err)
}
// Security: verify target host before sending token.
if err := ValidateTargetHost(fullURL); err != nil {
return nil, err
}
var bodyReader io.Reader
if req.Data != nil && method != "GET" {
data, marshalErr := json.Marshal(req.Data)
if marshalErr != nil {
return nil, fmt.Errorf("marshaling request body: %w", marshalErr)
}
bodyReader = bytes.NewReader(data)
}
httpReq, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
if err != nil {
return nil, fmt.Errorf("creating HTTP request: %w", err)
}
// Set headers and auth based on API style.
if IsLegacyAPI(fullURL) {
// Legacy API: token goes in query parameter.
parsed, _ := url.Parse(fullURL)
q := parsed.Query()
q.Set(LegacyAuthParam, c.Token)
parsed.RawQuery = q.Encode()
httpReq.URL = parsed
} else {
// New API: token goes in header.
httpReq.Header.Set(AuthHeader, c.Token)
}
if bodyReader != nil {
httpReq.Header.Set("Content-Type", "application/json")
}
httpReq.Header.Set("User-Agent", "dws-cli/raw-api")
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("executing HTTP request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response body: %w", err)
}
return &RawAPIResponse{
StatusCode: resp.StatusCode,
Header: resp.Header,
Body: body,
}, nil
}
// buildURL constructs the full request URL from path and query params.
func (c *APIClient) buildURL(path string, params map[string]any) (string, error) {
normalised := NormalisePath(path, c.BaseURL)
parsed, err := url.Parse(normalised)
if err != nil {
return "", fmt.Errorf("parsing URL %q: %w", normalised, err)
}
if len(params) > 0 {
q := parsed.Query()
for k, v := range params {
q.Set(k, fmt.Sprintf("%v", v))
}
parsed.RawQuery = q.Encode()
}
return parsed.String(), nil
}
// IsLegacyAPI returns true if the URL targets the legacy oapi.dingtalk.com endpoint.
// Legacy APIs use query-parameter authentication instead of header-based auth.
func IsLegacyAPI(urlStr string) bool {
lower := strings.ToLower(urlStr)
return strings.Contains(lower, "oapi.dingtalk.com") ||
strings.HasPrefix(lower, LegacyBaseURL)
}
// NormalisePath normalises an API path:
// - Full URLs are accepted as-is (after stripping query/fragment)
// - Relative paths are prefixed with the base URL
// - Query strings and fragments are stripped (must use --params)
func NormalisePath(path, baseURL string) string {
path = strings.TrimSpace(path)
// Strip query and fragment to force --params usage.
if idx := strings.IndexAny(path, "?#"); idx >= 0 {
path = path[:idx]
}
// Full URL: extract the path portion relative to the base.
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path
}
// Ensure leading slash.
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if strings.TrimSpace(baseURL) == "" {
baseURL = DefaultBaseURL
}
return strings.TrimRight(baseURL, "/") + path
}
// defaultTransport returns a tuned http.Transport matching the project conventions.
func defaultTransport() *http.Transport {
return &http.Transport{
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 20 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
ForceAttemptHTTP2: true,
}
}
+326
View File
@@ -0,0 +1,326 @@
// 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 apiclient
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestNewClient_DefaultBaseURL(t *testing.T) {
c := NewClient("tok", "")
if c.BaseURL != DefaultBaseURL {
t.Errorf("expected %q, got %q", DefaultBaseURL, c.BaseURL)
}
}
func TestNewClient_CustomBaseURL(t *testing.T) {
c := NewClient("tok", "https://custom.api.com/")
if c.BaseURL != "https://custom.api.com" {
t.Errorf("expected trailing slash stripped, got %q", c.BaseURL)
}
}
func TestNormalisePath(t *testing.T) {
tests := []struct {
path, base, want string
}{
{"/v1.0/users", "", "https://api.dingtalk.com/v1.0/users"},
{"v1.0/users", "", "https://api.dingtalk.com/v1.0/users"},
{"https://api.dingtalk.com/v1.0/users", "", "https://api.dingtalk.com/v1.0/users"},
{"/v1.0/users?foo=bar#frag", "", "https://api.dingtalk.com/v1.0/users"},
{"/v1.0/users", "https://custom.example.com", "https://custom.example.com/v1.0/users"},
}
for _, tt := range tests {
got := NormalisePath(tt.path, tt.base)
if got != tt.want {
t.Errorf("NormalisePath(%q, %q) = %q, want %q", tt.path, tt.base, got, tt.want)
}
}
}
func TestDo_Success(t *testing.T) {
AllowedHosts["127.0.0.1"] = true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(AuthHeader) != "test-token" {
t.Errorf("expected auth header %q, got %q", "test-token", r.Header.Get(AuthHeader))
}
if r.Method != "GET" {
t.Errorf("expected GET, got %s", r.Method)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]string{"name": "test"})
}))
defer srv.Close()
c := NewClient("test-token", srv.URL)
resp, err := c.Do(context.Background(), RawAPIRequest{
Method: "GET",
Path: "/v1.0/test",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
func TestDo_PostWithBody(t *testing.T) {
AllowedHosts["127.0.0.1"] = true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("expected POST, got %s", r.Method)
}
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("expected JSON content type")
}
var body map[string]string
json.NewDecoder(r.Body).Decode(&body)
if body["key"] != "value" {
t.Errorf("expected body key=value, got %v", body)
}
w.WriteHeader(200)
w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL)
resp, err := c.Do(context.Background(), RawAPIRequest{
Method: "POST",
Path: "/v1.0/test",
Data: map[string]string{"key": "value"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
func TestDo_InvalidMethod(t *testing.T) {
c := NewClient("tok", "")
_, err := c.Do(context.Background(), RawAPIRequest{
Method: "INVALID",
Path: "/test",
})
if err == nil {
t.Error("expected error for invalid method")
}
}
func TestDo_QueryParams(t *testing.T) {
AllowedHosts["127.0.0.1"] = true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("pageSize") != "10" {
t.Errorf("expected pageSize=10, got %v", r.URL.Query())
}
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL)
_, err := c.Do(context.Background(), RawAPIRequest{
Method: "GET",
Path: "/v1.0/test",
Params: map[string]any{"pageSize": 10},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestIsLegacyAPI(t *testing.T) {
tests := []struct {
url string
want bool
}{
{"https://api.dingtalk.com/v1.0/users", false},
{"https://oapi.dingtalk.com/topapi/v2/user/get", true},
{"https://OAPI.DINGTALK.COM/topapi/v2/user/get", true},
{"https://custom.example.com/api", false},
{"", false},
}
for _, tt := range tests {
got := IsLegacyAPI(tt.url)
if got != tt.want {
t.Errorf("IsLegacyAPI(%q) = %v, want %v", tt.url, got, tt.want)
}
}
}
func TestDo_LegacyAPI_TokenInQueryParam(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Legacy API: token should be in query param.
if r.URL.Query().Get(LegacyAuthParam) != "legacy-token" {
t.Errorf("expected access_token=legacy-token in query, got %v", r.URL.Query())
}
// Should NOT have the new-style auth header.
if r.Header.Get(AuthHeader) != "" {
t.Errorf("expected no auth header for legacy API, got %q", r.Header.Get(AuthHeader))
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
w.Write([]byte(`{"errcode":0,"errmsg":"ok","result":{"userid":"user1"}}`))
}))
defer srv.Close()
// Use full URL with oapi.dingtalk.com in the path, but redirect to test server.
// Since we can't DNS-resolve oapi.dingtalk.com, we use the test server URL
// and pass the full oapi URL as Path so that NormalisePath preserves it.
// Then we override the resolved URL in the client to point to our test server.
//
// Best approach: directly verify that buildURL + IsLegacyAPI routing works
// by testing buildURL output and calling Do with a custom transport that
// redirects oapi.dingtalk.com to our test server.
c := NewClient("legacy-token", "")
// Replace the transport to redirect oapi.dingtalk.com to test server.
c.HTTPClient.Transport = &legacyTestTransport{targetURL: srv.URL}
resp, err := c.Do(context.Background(), RawAPIRequest{
Method: "POST",
Path: "https://oapi.dingtalk.com/topapi/v2/user/get",
Data: map[string]string{"userid": "user1"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
// legacyTestTransport redirects requests from oapi.dingtalk.com to a local test server.
type legacyTestTransport struct {
targetURL string
}
func (t *legacyTestTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Rewrite the host to point to our test server, preserving path and query.
newURL := t.targetURL + req.URL.Path
if req.URL.RawQuery != "" {
newURL += "?" + req.URL.RawQuery
}
parsed, _ := url.Parse(newURL)
req.URL = parsed
req.Host = parsed.Host
return http.DefaultTransport.RoundTrip(req)
}
func TestNormalisePath_Legacy(t *testing.T) {
tests := []struct {
path, base, want string
}{
// Legacy full URL preserved.
{"https://oapi.dingtalk.com/topapi/v2/user/get", "", "https://oapi.dingtalk.com/topapi/v2/user/get"},
// Relative path with legacy base URL.
{"/topapi/v2/user/get", LegacyBaseURL, "https://oapi.dingtalk.com/topapi/v2/user/get"},
// Strip query from legacy URL.
{"https://oapi.dingtalk.com/topapi/v2/user/get?access_token=xxx", "", "https://oapi.dingtalk.com/topapi/v2/user/get"},
}
for _, tt := range tests {
got := NormalisePath(tt.path, tt.base)
if got != tt.want {
t.Errorf("NormalisePath(%q, %q) = %q, want %q", tt.path, tt.base, got, tt.want)
}
}
}
func TestResolvePageLimit(t *testing.T) {
t.Parallel()
tests := []struct {
raw, want int
}{
// 0 → unlimited → safety cap
{0, MaxPageLimit},
// normal usage
{3, 3},
// default
{10, 10},
// within cap
{100, 100},
// exactly cap
{MaxPageLimit, MaxPageLimit},
// exceeds cap
{MaxPageLimit + 100, MaxPageLimit},
// negative → default
{-1, DefaultPageLimit},
{-100, DefaultPageLimit},
}
for _, tt := range tests {
got := resolvePageLimit(tt.raw)
if got != tt.want {
t.Errorf("resolvePageLimit(%d) = %d, want %d", tt.raw, got, tt.want)
}
}
}
func TestPaginateAll_ProgressLog(t *testing.T) {
AllowedHosts["127.0.0.1"] = true
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
if callCount >= 3 {
json.NewEncoder(w).Encode(map[string]any{
"result": map[string]any{"has_more": false, "items": []any{1, 2}},
})
} else {
json.NewEncoder(w).Encode(map[string]any{
"result": map[string]any{
"has_more": true,
"next_cursor": 100,
"items": []any{callCount},
},
})
}
}))
defer srv.Close()
c := NewClient("test-token", srv.URL)
var logBuf bytes.Buffer
pages, err := c.PaginateAll(context.Background(), RawAPIRequest{
Method: "GET",
Path: "/v1.0/test",
}, PaginationOptions{
PageLimit: 5,
PageDelay: 0,
LogWriter: &logBuf,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(pages) != 3 {
t.Errorf("expected 3 pages, got %d", len(pages))
}
log := logBuf.String()
if !strings.Contains(log, "第 1 页") || !strings.Contains(log, "第 2 页") || !strings.Contains(log, "第 3 页") {
t.Errorf("expected progress log for each page, got: %s", log)
}
if !strings.Contains(log, "数据获取完成") {
t.Errorf("expected completion message, got: %s", log)
}
}
+63
View File
@@ -0,0 +1,63 @@
// 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 apiclient
import (
"encoding/json"
"fmt"
"io"
"strings"
)
// MaskToken returns a masked version of a token for display in dry-run
// and log output. Shows the first 4 characters followed by "****".
func MaskToken(token string) string {
if len(token) <= 4 {
return "****"
}
return token[:4] + "****"
}
// PrintDryRun outputs a dry-run preview of the API request that would be sent.
func PrintDryRun(w io.Writer, req RawAPIRequest, baseURL, token string) error {
fullURL := NormalisePath(req.Path, baseURL)
fmt.Fprintln(w, "=== Dry Run ===")
fmt.Fprintf(w, "%-12s%s\n", "Method:", strings.ToUpper(req.Method))
fmt.Fprintf(w, "%-12s%s\n", "URL:", fullURL)
if len(req.Params) > 0 {
paramsJSON, err := json.MarshalIndent(req.Params, " ", " ")
if err == nil {
fmt.Fprintf(w, "%-12s%s\n", "Params:", string(paramsJSON))
}
}
if req.Data != nil {
dataJSON, err := json.MarshalIndent(req.Data, " ", " ")
if err == nil {
fmt.Fprintf(w, "%-12s%s\n", "Body:", string(dataJSON))
}
}
if IsLegacyAPI(fullURL) {
fmt.Fprintf(w, "%-12s%s=%s\n", "Auth:", LegacyAuthParam, MaskToken(token))
fmt.Fprintf(w, "%-12s%s\n", "Style:", "旧版 (oapi.dingtalk.com)")
} else {
fmt.Fprintf(w, "%-12s%s: %s\n", "Auth:", AuthHeader, MaskToken(token))
fmt.Fprintf(w, "%-12s%s\n", "Style:", "新版 (api.dingtalk.com)")
}
fmt.Fprintln(w, "===============")
return nil
}
+226
View File
@@ -0,0 +1,226 @@
// 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 apiclient
import (
"context"
"encoding/json"
"fmt"
"io"
"time"
)
const (
// DefaultPageLimit is the maximum number of pages fetched with --page-all
// when --page-limit is not explicitly set.
DefaultPageLimit = 10
// MaxPageLimit is the hard safety cap to prevent infinite loops when an
// API endpoint has a bug that causes has_more to never become false.
// Use --page-limit 0 to hit this cap; any explicit positive value is
// honoured up to this ceiling.
MaxPageLimit = 500
// DefaultPageDelay is the delay between paginated requests in milliseconds.
DefaultPageDelay = 200
)
// PaginationOptions controls automatic pagination behaviour.
type PaginationOptions struct {
PageLimit int // Maximum pages (0 = unlimited, capped at MaxPageLimit)
PageDelay int // Delay between pages in milliseconds
LogWriter io.Writer // Optional: progress log output (typically stderr)
}
// PaginateAll fetches all pages of a paginated API and merges the results.
// DingTalk APIs use two pagination patterns:
// - cursor/next_cursor/has_more (in response body)
// - next_token (in response body)
//
// The function auto-detects which pattern the API uses.
func (c *APIClient) PaginateAll(ctx context.Context, req RawAPIRequest, opts PaginationOptions) ([]any, error) {
limit := resolvePageLimit(opts.PageLimit)
if opts.PageDelay <= 0 {
opts.PageDelay = DefaultPageDelay
}
var allResults []any
pageCount := 0
for {
pageCount++
// Safety cap — only break if a carry is active (pageCount > 1).
if limit > 0 && pageCount > limit {
logf(opts.LogWriter, "[pagination] ⚠ 已达安全上限 %d 页,停止翻页。数据可能不完整,请检查 API 是否异常。\n", limit)
break
}
logf(opts.LogWriter, "[pagination] 第 %d 页 请求中...\n", pageCount)
resp, err := c.Do(ctx, req)
if err != nil {
if pageCount == 1 {
return nil, err
}
// Non-first page error: return what we have so far.
return allResults, fmt.Errorf("分页第 %d 页请求失败 (已获取 %d 页结果): %w", pageCount, pageCount-1, err)
}
result, hasMore, nextToken, parseErr := parsePaginatedResponse(resp)
if parseErr != nil {
if pageCount == 1 {
return nil, parseErr
}
// Non-first page parse failure: warn the caller so users aren't
// silently left with incomplete data.
logf(opts.LogWriter, "[pagination] ⚠ 第 %d 页解析失败,停止翻页并返回已获取的 %d 页数据: %v\n", pageCount, pageCount-1, parseErr)
return allResults, nil
}
allResults = append(allResults, result)
if !hasMore || nextToken == "" {
logf(opts.LogWriter, "[pagination] 数据获取完成 (共 %d 页)\n", pageCount)
break
}
// Inject the next page token into the request.
req = injectPageToken(req, nextToken)
// Delay between pages to prevent API throttling.
select {
case <-ctx.Done():
return allResults, ctx.Err()
case <-time.After(time.Duration(opts.PageDelay) * time.Millisecond):
}
}
return allResults, nil
}
// resolvePageLimit translates the user-facing value into an internal limit:
//
// 0 → MaxPageLimit (user wants unlimited; safety cap applies)
// positive N → min(N, MaxPageLimit) (explicit page limit, still capped)
// negative → DefaultPageLimit (invalid input treated as default)
func resolvePageLimit(raw int) int {
if raw == 0 {
return MaxPageLimit
}
if raw < 0 {
return DefaultPageLimit
}
if raw > MaxPageLimit {
return MaxPageLimit
}
return raw
}
func logf(w io.Writer, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, format, args...)
}
// parsePaginatedResponse extracts the response payload and pagination info.
// It auto-detects DingTalk's two pagination patterns.
func parsePaginatedResponse(resp *RawAPIResponse) (result any, hasMore bool, nextToken string, err error) {
contentType := resp.Header.Get("Content-Type")
if !isJSONContentType(contentType) {
return nil, false, "", fmt.Errorf("分页响应非 JSON 格式 (Content-Type: %s)", contentType)
}
if len(resp.Body) == 0 {
return nil, false, "", fmt.Errorf("分页响应体为空 (HTTP %d)", resp.StatusCode)
}
var payload map[string]any
if unmarshalErr := jsonUnmarshal(resp.Body, &payload); unmarshalErr != nil {
return nil, false, "", fmt.Errorf("解析分页 JSON 响应失败: %w", unmarshalErr)
}
// Check for DingTalk errors first.
if apiErr := checkDingTalkError(payload, resp.StatusCode); apiErr != nil {
return nil, false, "", apiErr
}
// Pattern 1: cursor/next_cursor/has_more (often nested in "result" or top-level)
if resultObj, ok := payload["result"]; ok {
if resultMap, isMap := resultObj.(map[string]any); isMap {
hasMore, _ = resultMap["has_more"].(bool)
if nc, ok := resultMap["next_cursor"].(float64); ok && nc > 0 {
nextToken = fmt.Sprintf("%.0f", nc)
}
return payload, hasMore, nextToken, nil
}
}
// Top-level has_more / next_cursor
if hm, ok := payload["has_more"]; ok {
hasMore, _ = hm.(bool)
}
if nc, ok := payload["next_cursor"]; ok {
if ncf, isFloat := nc.(float64); isFloat && ncf > 0 {
nextToken = fmt.Sprintf("%.0f", ncf)
}
}
// Pattern 2: next_token
if nt, ok := payload["next_token"]; ok {
if nts, isStr := nt.(string); isStr && nts != "" {
nextToken = nts
hasMore = true
}
}
return payload, hasMore, nextToken, nil
}
// injectPageToken injects the pagination token into the next request.
// For GET requests, it's added as a query param; for POST, it's in the body.
func injectPageToken(req RawAPIRequest, token string) RawAPIRequest {
method := req.Method
if method == "GET" {
if req.Params == nil {
req.Params = make(map[string]any)
}
// Try to detect which param name the API uses
if _, ok := req.Params["cursor"]; ok {
req.Params["cursor"] = token
} else if _, ok := req.Params["next_token"]; ok {
req.Params["next_token"] = token
} else {
// Default to next_token for GET requests
req.Params["next_token"] = token
}
} else {
// For POST/PUT requests, inject into the body
if bodyMap, ok := req.Data.(map[string]any); ok {
if _, hasCursor := bodyMap["cursor"]; hasCursor {
bodyMap["cursor"] = token
} else {
bodyMap["next_token"] = token
}
req.Data = bodyMap
}
}
return req
}
// jsonUnmarshal is a helper for JSON unmarshaling.
func jsonUnmarshal(data []byte, v any) error {
return json.Unmarshal(data, v)
}
+102
View File
@@ -0,0 +1,102 @@
// 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 apiclient
import (
"encoding/json"
"fmt"
"io"
"strings"
)
// ParseJSONMap parses a --params flag value into a map[string]any.
// Supports:
// - JSON string: '{"key":"value"}'
// - "-" to read from stdin
// - Empty string returns nil (no params)
func ParseJSONMap(raw, flagName string, stdin io.Reader) (map[string]any, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
if raw == "-" {
data, err := io.ReadAll(stdin)
if err != nil {
return nil, fmt.Errorf("从 stdin 读取 %s 失败: %w", flagName, err)
}
raw = strings.TrimSpace(string(data))
if raw == "" {
return nil, nil
}
}
// Strip wrapping single quotes (common shell escaping).
raw = stripSingleQuotes(raw)
var result map[string]any
if err := json.Unmarshal([]byte(raw), &result); err != nil {
return nil, fmt.Errorf("解析 %s JSON 失败: %w\n输入: %s", flagName, err, truncate(raw, 200))
}
return result, nil
}
// ParseOptionalBody parses a --data flag value into a request body.
// Returns nil for empty input. GET requests are not allowed to have a body.
func ParseOptionalBody(method, raw string, stdin io.Reader) (any, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
if strings.ToUpper(method) == "GET" && raw != "" {
return nil, fmt.Errorf("GET 请求不允许使用 --data 参数")
}
if raw == "-" {
data, err := io.ReadAll(stdin)
if err != nil {
return nil, fmt.Errorf("从 stdin 读取 --data 失败: %w", err)
}
raw = strings.TrimSpace(string(data))
if raw == "" {
return nil, nil
}
}
// Strip wrapping single quotes.
raw = stripSingleQuotes(raw)
var result any
if err := json.Unmarshal([]byte(raw), &result); err != nil {
return nil, fmt.Errorf("解析 --data JSON 失败: %w\n输入: %s", err, truncate(raw, 200))
}
return result, nil
}
// stripSingleQuotes removes a leading and trailing single quote pair.
func stripSingleQuotes(s string) string {
if len(s) >= 2 && s[0] == '\'' && s[len(s)-1] == '\'' {
return s[1 : len(s)-1]
}
return s
}
// truncate returns at most n characters of s, appending "..." if truncated.
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+123
View File
@@ -0,0 +1,123 @@
// 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 apiclient
import (
"strings"
"testing"
)
func TestParseJSONMap_Empty(t *testing.T) {
result, err := ParseJSONMap("", "--params", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil {
t.Errorf("expected nil, got %v", result)
}
}
func TestParseJSONMap_ValidJSON(t *testing.T) {
result, err := ParseJSONMap(`{"key":"value","num":42}`, "--params", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result["key"] != "value" {
t.Errorf("expected key=value, got %v", result["key"])
}
}
func TestParseJSONMap_SingleQuotes(t *testing.T) {
result, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result["key"] != "value" {
t.Errorf("expected key=value, got %v", result["key"])
}
}
func TestParseJSONMap_Stdin(t *testing.T) {
stdin := strings.NewReader(`{"from":"stdin"}`)
result, err := ParseJSONMap("-", "--params", stdin)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result["from"] != "stdin" {
t.Errorf("expected from=stdin, got %v", result["from"])
}
}
func TestParseJSONMap_InvalidJSON(t *testing.T) {
_, err := ParseJSONMap("not json", "--params", nil)
if err == nil {
t.Error("expected error for invalid JSON")
}
}
func TestParseOptionalBody_Empty(t *testing.T) {
result, err := ParseOptionalBody("POST", "", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil {
t.Errorf("expected nil, got %v", result)
}
}
func TestParseOptionalBody_GETNotAllowed(t *testing.T) {
_, err := ParseOptionalBody("GET", `{"data":true}`, nil)
if err == nil {
t.Error("expected error for GET with body")
}
}
func TestParseOptionalBody_ValidPOST(t *testing.T) {
result, err := ParseOptionalBody("POST", `{"key":"value"}`, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", result)
}
if m["key"] != "value" {
t.Errorf("expected key=value, got %v", m["key"])
}
}
func TestStripSingleQuotes(t *testing.T) {
tests := []struct{ in, want string }{
{`'hello'`, `hello`},
{`"hello"`, `"hello"`},
{`hello`, `hello`},
{`''`, ``},
{`'`, `'`},
}
for _, tt := range tests {
got := stripSingleQuotes(tt.in)
if got != tt.want {
t.Errorf("stripSingleQuotes(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestTruncate(t *testing.T) {
if got := truncate("hello", 10); got != "hello" {
t.Errorf("expected hello, got %q", got)
}
if got := truncate("hello world", 5); got != "hello..." {
t.Errorf("expected hello..., got %q", got)
}
}
+179
View File
@@ -0,0 +1,179 @@
// 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 apiclient
import (
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
// ResponseOptions controls how an API response is processed.
type ResponseOptions struct {
OutputPath string // --output file path for binary responses
Format output.Format // output format (json|table|raw)
JqExpr string // --jq expression
Fields string // --fields comma-separated field names
Out io.Writer // stdout
ErrOut io.Writer // stderr
}
// HandleResponse routes response processing based on Content-Type and status code.
func HandleResponse(resp *RawAPIResponse, opts ResponseOptions) error {
contentType := resp.Header.Get("Content-Type")
isJSON := isJSONContentType(contentType)
// HTTP error with non-JSON body: print as plain text error.
if resp.StatusCode >= 400 && !isJSON {
return fmt.Errorf("API 请求失败 (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(resp.Body)))
}
// JSON response
if isJSON {
return handleJSONResponse(resp, opts)
}
// Binary response
return handleBinaryResponse(resp, opts)
}
// handleJSONResponse parses the JSON body, checks for DingTalk business errors,
// and writes the output using the configured format and filters.
func handleJSONResponse(resp *RawAPIResponse, opts ResponseOptions) error {
if len(resp.Body) == 0 {
return fmt.Errorf("API 返回空响应体 (HTTP %d),如需下载文件请使用 --output 参数", resp.StatusCode)
}
var payload any
if err := json.Unmarshal(resp.Body, &payload); err != nil {
return fmt.Errorf("解析 JSON 响应失败: %w", err)
}
// Check for DingTalk business error: {"errcode": xxx, "errmsg": "xxx"}
if apiErr := checkDingTalkError(payload, resp.StatusCode); apiErr != nil {
return apiErr
}
return output.WriteFiltered(opts.Out, opts.Format, payload, opts.Fields, opts.JqExpr)
}
// checkDingTalkError inspects a parsed JSON response for DingTalk error codes.
// Returns nil if no error is detected.
func checkDingTalkError(payload any, statusCode int) error {
obj, ok := payload.(map[string]any)
if !ok {
return nil
}
// Check for errcode != 0
if errcode, hasCode := obj["errcode"]; hasCode {
code := toFloat64(errcode)
if code != 0 {
errmsg, _ := obj["errmsg"].(string)
if errmsg == "" {
errmsg = "unknown error"
}
return fmt.Errorf("API 业务错误 (errcode: %.0f, HTTP %d): %s", code, statusCode, errmsg)
}
}
// Also check HTTP error status even if no errcode field
if statusCode >= 400 {
errmsg, _ := obj["errmsg"].(string)
if errmsg == "" {
errmsg, _ = obj["message"].(string)
}
if errmsg == "" {
errmsg, _ = obj["error"].(string)
}
if errmsg != "" {
return fmt.Errorf("API 请求失败 (HTTP %d): %s", statusCode, errmsg)
}
return fmt.Errorf("API 请求失败 (HTTP %d)", statusCode)
}
return nil
}
// handleBinaryResponse saves the response body to a file.
func handleBinaryResponse(resp *RawAPIResponse, opts ResponseOptions) error {
outputPath := strings.TrimSpace(opts.OutputPath)
if outputPath == "" {
// Try to infer filename from Content-Disposition header.
outputPath = inferFilename(resp.Header)
if outputPath == "" {
return fmt.Errorf("响应为非 JSON 格式 (Content-Type: %s),请使用 --output 指定保存路径",
resp.Header.Get("Content-Type"))
}
}
dir := filepath.Dir(outputPath)
if dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("创建输出目录失败: %w", err)
}
}
if err := os.WriteFile(outputPath, resp.Body, 0o644); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
fmt.Fprintf(opts.ErrOut, "已保存到: %s (%d 字节)\n", outputPath, len(resp.Body))
return nil
}
// inferFilename tries to extract a filename from the Content-Disposition header.
func inferFilename(header http.Header) string {
cd := header.Get("Content-Disposition")
if cd == "" {
return ""
}
_, params, err := mime.ParseMediaType(cd)
if err != nil {
return ""
}
return strings.TrimSpace(params["filename"])
}
// isJSONContentType returns true if the Content-Type indicates JSON.
func isJSONContentType(ct string) bool {
ct = strings.TrimSpace(strings.ToLower(ct))
return strings.HasPrefix(ct, "application/json") ||
strings.HasPrefix(ct, "text/json") ||
strings.Contains(ct, "+json")
}
// toFloat64 attempts to convert a JSON number to float64.
func toFloat64(v any) float64 {
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
case int64:
return float64(n)
case json.Number:
f, _ := n.Float64()
return f
}
return 0
}
+146
View File
@@ -0,0 +1,146 @@
// 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 apiclient
import (
"fmt"
"net/url"
"strings"
)
// AllowedHosts is the set of trusted DingTalk API hosts.
// Only these hosts may receive access tokens to prevent token leakage.
var AllowedHosts = map[string]bool{
"api.dingtalk.com": true,
"oapi.dingtalk.com": true,
}
// ValidateTargetHost checks that the resolved request URL targets a trusted
// DingTalk host. This prevents access-token leakage to arbitrary domains.
func ValidateTargetHost(fullURL string) error {
parsed, err := url.Parse(fullURL)
if err != nil {
return fmt.Errorf("无法解析请求 URL: %w", err)
}
host := strings.ToLower(parsed.Hostname())
if !AllowedHosts[host] {
return fmt.Errorf(
"安全限制: 目标域名 %q 不在允许列表中。\n"+
"dws api 仅允许向以下域名发起请求:\n"+
" - api.dingtalk.com (新版 API)\n"+
" - oapi.dingtalk.com (旧版 API)\n"+
"请检查 URL 或 --base-url 参数是否正确。",
host,
)
}
return nil
}
// ValidateMethod checks that the HTTP method is one of the five allowed methods.
func ValidateMethod(method string) (string, error) {
upper := strings.ToUpper(strings.TrimSpace(method))
if !AllowedMethods[upper] {
return "", fmt.Errorf("不支持的 HTTP 方法: %s (允许: GET, POST, PUT, PATCH, DELETE)", method)
}
return upper, nil
}
// ValidatePath checks the API path for injection attacks and dangerous characters.
func ValidatePath(path string) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("API 路径不能为空")
}
if err := rejectDangerousChars(path, "path"); err != nil {
return err
}
// Reject path traversal
if strings.Contains(path, "..") {
return fmt.Errorf("API 路径不能包含 '..' (路径遍历)")
}
return nil
}
// ValidateUserInput checks a user-provided string for control characters and
// dangerous Unicode codepoints that could enable injection attacks.
func ValidateUserInput(value, fieldName string) error {
return rejectDangerousChars(value, fieldName)
}
// rejectDangerousChars rejects C0 control characters (except \t and \n),
// DEL (0x7F), and dangerous Unicode codepoints in a string.
func rejectDangerousChars(s, fieldName string) error {
for i, r := range s {
// Allow tab and newline
if r == '\t' || r == '\n' {
continue
}
// Reject C0 control chars (0x00-0x1F) and DEL (0x7F)
if r < 0x20 || r == 0x7F {
return fmt.Errorf("%s 包含非法控制字符 (位置 %d, U+%04X)", fieldName, i, r)
}
// Reject dangerous Unicode
if isDangerousUnicode(r) {
return fmt.Errorf("%s 包含危险 Unicode 字符 (位置 %d, U+%04X)", fieldName, i, r)
}
}
return nil
}
// isDangerousUnicode returns true for Unicode codepoints that can be used
// for visual spoofing or terminal injection attacks.
func isDangerousUnicode(r rune) bool {
switch {
// Zero-width characters
case r >= 0x200B && r <= 0x200D:
return true
// BOM
case r == 0xFEFF:
return true
// Bidi override characters
case r >= 0x202A && r <= 0x202E:
return true
// Line/paragraph separator
case r == 0x2028 || r == 0x2029:
return true
// Bidi isolate characters
case r >= 0x2066 && r <= 0x2069:
return true
// Additional Bidi controls
case r == 0x061C:
return true
// Non-characters
case r >= 0xFDD0 && r <= 0xFDEF:
return true
}
// Object replacement (U+FFFC) / replacement (U+FFFD) characters and
// other non-printable non-ASCII runes (e.g. CJK, symbols) are allowed
// through — only the explicit dangerous ranges above are blocked.
return false
}
// ValidateStdinExclusion checks that --params and --data don't both read from stdin.
func ValidateStdinExclusion(params, data string) error {
if strings.TrimSpace(params) == "-" && strings.TrimSpace(data) == "-" {
return fmt.Errorf("--params 和 --data 不能同时从 stdin 读取 (-)")
}
return nil
}
// ValidateFlagExclusion checks mutual exclusion between flags.
func ValidateFlagExclusion(outputPath string, pageAll bool) error {
if strings.TrimSpace(outputPath) != "" && pageAll {
return fmt.Errorf("--output 和 --page-all 不能同时使用")
}
return nil
}
+155
View File
@@ -0,0 +1,155 @@
// 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 apiclient
import (
"strings"
"testing"
)
func TestValidateMethod(t *testing.T) {
valid := []string{"GET", "get", "Post", "put", "PATCH", "delete"}
for _, m := range valid {
got, err := ValidateMethod(m)
if err != nil {
t.Errorf("ValidateMethod(%q) unexpected error: %v", m, err)
}
if got != strings.ToUpper(m) {
t.Errorf("ValidateMethod(%q) = %q, want %q", m, got, strings.ToUpper(m))
}
}
invalid := []string{"HEAD", "OPTIONS", "TRACE", "CONNECT", "INVALID", ""}
for _, m := range invalid {
_, err := ValidateMethod(m)
if err == nil {
t.Errorf("ValidateMethod(%q) expected error, got nil", m)
}
}
}
func TestValidatePath(t *testing.T) {
// Valid paths
for _, p := range []string{"/v1.0/users", "/v2.0/calendar/events", "v1.0/contact/users/me"} {
if err := ValidatePath(p); err != nil {
t.Errorf("ValidatePath(%q) unexpected error: %v", p, err)
}
}
// Empty path
if err := ValidatePath(""); err == nil {
t.Error("ValidatePath(\"\") expected error")
}
// Path traversal
if err := ValidatePath("/v1.0/../secret"); err == nil {
t.Error("ValidatePath with .. expected error")
}
// Control character
if err := ValidatePath("/v1.0/\x00test"); err == nil {
t.Error("ValidatePath with null byte expected error")
}
}
func TestRejectDangerousUnicode(t *testing.T) {
// Zero-width space
if err := ValidateUserInput("hello\u200Bworld", "test"); err == nil {
t.Error("expected error for zero-width space")
}
// BOM
if err := ValidateUserInput("\uFEFFhello", "test"); err == nil {
t.Error("expected error for BOM")
}
// Bidi override
if err := ValidateUserInput("hello\u202Aworld", "test"); err == nil {
t.Error("expected error for bidi override")
}
// Normal string should pass
if err := ValidateUserInput("hello world 你好", "test"); err != nil {
t.Errorf("unexpected error for normal string: %v", err)
}
}
func TestValidateStdinExclusion(t *testing.T) {
if err := ValidateStdinExclusion("-", "-"); err == nil {
t.Error("expected error when both params and data read from stdin")
}
if err := ValidateStdinExclusion("-", "{}"); err != nil {
t.Errorf("unexpected error: %v", err)
}
if err := ValidateStdinExclusion("{}", "-"); err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestValidateFlagExclusion(t *testing.T) {
if err := ValidateFlagExclusion("output.json", true); err == nil {
t.Error("expected error when --output and --page-all both set")
}
if err := ValidateFlagExclusion("output.json", false); err != nil {
t.Errorf("unexpected error: %v", err)
}
if err := ValidateFlagExclusion("", true); err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestMaskToken(t *testing.T) {
tests := []struct {
in, want string
}{
{"", "****"},
{"abc", "****"},
{"abcd", "****"},
{"abcde", "abcd****"},
{"abcdefghij", "abcd****"},
}
for _, tt := range tests {
got := MaskToken(tt.in)
if got != tt.want {
t.Errorf("MaskToken(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestValidateTargetHost(t *testing.T) {
// Allowed hosts
allowed := []string{
"https://api.dingtalk.com/v1.0/contact/users/me",
"https://oapi.dingtalk.com/topapi/v2/user/get",
"https://API.DINGTALK.COM/v1.0/test",
"https://OAPI.DINGTALK.COM/topapi/test",
}
for _, u := range allowed {
if err := ValidateTargetHost(u); err != nil {
t.Errorf("ValidateTargetHost(%q) unexpected error: %v", u, err)
}
}
// Blocked hosts
blocked := []string{
"https://oapi.dingtalk.fakedomain.com/topapi/v2/user/get",
"https://fake.com/v1.0/test",
"https://api.dingtalk.com.evil.com/v1.0/test",
"https://evil.com/redirect?url=https://api.dingtalk.com",
"http://localhost:8080/v1.0/test",
"https://dingtalk.com/v1.0/test",
}
for _, u := range blocked {
if err := ValidateTargetHost(u); err == nil {
t.Errorf("ValidateTargetHost(%q) expected error, got nil", u)
}
}
}
+325
View File
@@ -0,0 +1,325 @@
// 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"
"fmt"
"os"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/apiclient"
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/output"
"github.com/spf13/cobra"
)
// apiFlags holds the flags specific to the `dws api` command.
type apiFlags struct {
params string
data string
pageAll bool
pageLimit int
pageDelay int
baseURL string
}
// newAPICommand creates the `dws api` subcommand for raw DingTalk OpenAPI calls.
func newAPICommand(flags *GlobalFlags) *cobra.Command {
af := &apiFlags{}
cmd := &cobra.Command{
Use: "api <METHOD> <PATH> [flags]",
Short: "调用钉钉 OpenAPI (Raw HTTP)",
Long: `直接调用钉钉 OpenAPI,支持 api.dingtalk.com 和 oapi.dingtalk.com 两个域名。
api.dingtalk.com:
Token 通过 HTTP Header (x-acs-dingtalk-access-token) 传递。
路径格式: /v1.0/xxx 或 /v2.0/xxx
oapi.dingtalk.com:
Token 通过 URL 查询参数 (access_token) 传递。
路径格式: /topapi/v2/xxx 或完整 URL https://oapi.dingtalk.com/topapi/...
仅限使用自有应用凭证(--client-id/--client-secret)登录后使用。
通过 MCP 默认凭证登录获取的加密 token 不支持 raw API 调用。
示例:
# === api.dingtalk.com ===
# 获取当前用户信息
dws api GET /v1.0/contact/users/me
# 搜索用户 (POST + JSON body)
dws api POST /v1.0/contact/users/search \
--data '{"queryWord":"张三","offset":0,"size":10}'
# 创建日历事件
dws api POST /v1.0/calendar/users/me/calendars/primary/events \
--data '{"summary":"Team Meeting","start":{"dateTime":"2026-01-01T10:00:00+08:00"}}'
# === oapi.dingtalk.com ===
# 获取用户详情 (使用 --base-url)
dws api POST /topapi/v2/user/get \
--base-url https://oapi.dingtalk.com \
--data '{"userid":"manager123"}'
# 也可以直接使用完整 URL
dws api POST https://oapi.dingtalk.com/topapi/v2/user/get \
--data '{"userid":"manager123"}'
# === 通用功能 ===
# 分页获取所有结果
dws api GET /v1.0/attendance/groups --page-all --page-limit 5
# Dry-run 预览请求
dws api GET /v1.0/contact/users/me --dry-run
# 使用 jq 过滤输出
dws api GET /v1.0/contact/users/me --jq '.nick'`,
Args: cobra.ExactArgs(2),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPI(cmd, args, flags, af)
},
}
cmd.Flags().StringVar(&af.params, "params", "", "查询参数 JSON (支持 - 从 stdin 读取)")
cmd.Flags().StringVar(&af.data, "data", "", "请求体 JSON (支持 - 从 stdin 读取)")
cmd.Flags().BoolVar(&af.pageAll, "page-all", false, "自动遍历所有分页")
cmd.Flags().IntVar(&af.pageLimit, "page-limit", apiclient.DefaultPageLimit, "最大翻页数 (0=不限, 默认10, 硬上限500)")
cmd.Flags().IntVar(&af.pageDelay, "page-delay", apiclient.DefaultPageDelay, "分页间隔毫秒")
cmd.Flags().StringVar(&af.baseURL, "base-url", "", "覆盖 API 基础 URL (默认 https://api.dingtalk.com)")
return cmd
}
// runAPI is the main execution logic for `dws api`.
func runAPI(cmd *cobra.Command, args []string, gf *GlobalFlags, af *apiFlags) error {
ctx := cmd.Context()
method := args[0]
path := args[1]
// 0. Reject path with inline query string — must use --params instead.
if idx := strings.IndexByte(path, '?'); idx >= 0 {
cleanPath := path[:idx]
// Parse query string to generate the exact --params JSON for the user.
paramsJSON := parseQueryStringToJSON(path[idx+1:])
return apperrors.NewValidation(
"API 路径中不允许直接拼接查询参数(?key=value),该写法会导致参数在解析时被静默丢弃。\n\n"+
"命令格式可参考:\n\n"+
" dws api "+method+" "+cleanPath+" --params '"+paramsJSON+"'",
apperrors.WithHint("查询参数必须通过 --params 传递,形如 --params '{\"key\":\"value\"}'"),
)
}
// 1. Validate HTTP method.
method, err := apiclient.ValidateMethod(method)
if err != nil {
return apperrors.NewValidation(err.Error())
}
// 2. Validate API path.
if err := apiclient.ValidatePath(path); err != nil {
return apperrors.NewValidation(err.Error())
}
// 3. Validate input safety for params and data.
if err := apiclient.ValidateUserInput(af.params, "--params"); err != nil {
return apperrors.NewValidation(err.Error())
}
if err := apiclient.ValidateUserInput(af.data, "--data"); err != nil {
return apperrors.NewValidation(err.Error())
}
// 4. Validate mutual exclusion.
if err := apiclient.ValidateStdinExclusion(af.params, af.data); err != nil {
return apperrors.NewValidation(err.Error())
}
if err := apiclient.ValidateFlagExclusion(gf.Output, af.pageAll); err != nil {
return apperrors.NewValidation(err.Error())
}
// 5. Parse --params.
params, err := apiclient.ParseJSONMap(af.params, "--params", os.Stdin)
if err != nil {
return apperrors.NewValidation(err.Error())
}
// 6. Parse --data.
body, err := apiclient.ParseOptionalBody(method, af.data, os.Stdin)
if err != nil {
return apperrors.NewValidation(err.Error())
}
// 7. Normalise and validate target URL.
fullURL := apiclient.NormalisePath(path, af.baseURL)
// 7b. Security: validate target host is a trusted DingTalk domain.
if err := apiclient.ValidateTargetHost(fullURL); err != nil {
return apperrors.NewValidation(err.Error())
}
// 8. Resolve app-level token (with timeout).
tokenCtx, tokenCancel := context.WithTimeout(ctx, 15*time.Second)
defer tokenCancel()
token, err := resolveRawAPIToken(tokenCtx, gf.Token)
if err != nil {
return err
}
// 9. Build request.
req := apiclient.RawAPIRequest{
Method: method,
Path: path,
Params: params,
Data: body,
}
baseURL := af.baseURL
// 10. Dry-run mode.
if gf.DryRun {
return apiclient.PrintDryRun(cmd.OutOrStdout(), req, baseURL, token)
}
// 11. Create client with timeout.
client := apiclient.NewClient(token, baseURL)
if gf.Timeout > 0 {
client.HTTPClient.Timeout = time.Duration(gf.Timeout) * time.Second
}
// 12. Execute request (with or without pagination).
format := output.Format(gf.Format)
respOpts := apiclient.ResponseOptions{
OutputPath: gf.Output,
Format: format,
JqExpr: gf.JQ,
Fields: gf.Fields,
Out: cmd.OutOrStdout(),
ErrOut: cmd.ErrOrStderr(),
}
if af.pageAll {
return runPaginated(ctx, client, req, af, respOpts)
}
resp, err := client.Do(ctx, req)
if err != nil {
return apperrors.NewAPI(fmt.Sprintf("API 请求失败: %v", err))
}
return apiclient.HandleResponse(resp, respOpts)
}
// runPaginated executes a paginated API request and outputs all results.
func runPaginated(ctx context.Context, client *apiclient.APIClient, req apiclient.RawAPIRequest, af *apiFlags, opts apiclient.ResponseOptions) error {
pages, err := client.PaginateAll(ctx, req, apiclient.PaginationOptions{
PageLimit: af.pageLimit,
PageDelay: af.pageDelay,
LogWriter: opts.ErrOut,
})
if err != nil && len(pages) == 0 {
return apperrors.NewAPI(fmt.Sprintf("分页请求失败: %v", err))
}
// Output all pages as a JSON array.
return output.WriteFiltered(opts.Out, opts.Format, pages, opts.Fields, opts.JqExpr)
}
// parseQueryStringToJSON parses a raw URL query string into a JSON object string.
// Uses simple & and = splitting (no URL decoding) to preserve values as-is.
func parseQueryStringToJSON(rawQuery string) string {
rawQuery = strings.TrimSpace(rawQuery)
if rawQuery == "" {
return "{}"
}
paramsMap := make(map[string]any)
for _, pair := range strings.Split(rawQuery, "&") {
kv := strings.SplitN(pair, "=", 2)
key := strings.TrimSpace(kv[0])
if key == "" {
continue
}
var val string
if len(kv) == 2 {
val = strings.TrimSpace(kv[1])
}
if val == "" {
continue // skip empty values like nextToken=
}
paramsMap[key] = val
}
if len(paramsMap) == 0 {
return "{}"
}
data, err := json.Marshal(paramsMap)
if err != nil {
return "{}"
}
return string(data)
}
// resolveRawAPIToken resolves an app-level access token for raw API calls.
// It uses AppTokenProvider to fetch from the unified POST /v1.0/oauth2/accessToken
// endpoint. The same token works for both api.dingtalk.com and oapi.dingtalk.com.
// Tokens are cached in keychain and auto-refreshed when expired.
func resolveRawAPIToken(ctx context.Context, explicitToken string) (string, error) {
// Explicit --token flag takes priority (user knows what they're doing).
if t := strings.TrimSpace(explicitToken); t != "" {
return t, nil
}
// Resolve app credentials (clientID/clientSecret).
appKey := authpkg.ClientID()
appSecret := authpkg.ClientSecret()
if appKey == "" || appSecret == "" || strings.HasPrefix(appKey, "<") || strings.HasPrefix(appSecret, "<") {
return "", apperrors.NewAuth(
"缺少应用凭证。dws api 需要使用自有应用的 AppKey/AppSecret 获取 accessToken。\n\n" +
"解决方法:\n" +
" 1. 使用自有应用凭证登录:\n" +
" dws auth login --client-id <APP_KEY> --client-secret <APP_SECRET>\n\n" +
" 2. 或通过环境变量设置:\n" +
" export DWS_CLIENT_ID=<APP_KEY>\n" +
" export DWS_CLIENT_SECRET=<APP_SECRET>\n" +
" dws auth login\n\n" +
"说明: 通过 MCP 默认凭证登录的加密 token 无法用于 raw API 调用。",
)
}
// Use AppTokenProvider for automatic caching and refresh.
configDir := defaultConfigDir()
provider := &authpkg.AppTokenProvider{
ConfigDir: configDir,
AppKey: appKey,
AppSecret: appSecret,
}
token, err := provider.GetToken(ctx)
if err != nil {
return "", apperrors.NewAuth(fmt.Sprintf("获取应用级访问令牌失败: %v", err))
}
if strings.TrimSpace(token) == "" {
return "", apperrors.NewAuth("应用级访问令牌为空,请检查应用凭证是否正确")
}
return strings.TrimSpace(token), nil
}
+110
View File
@@ -0,0 +1,110 @@
// 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"
"strings"
"testing"
)
func TestParseQueryStringToJSON(t *testing.T) {
t.Parallel()
tests := []struct {
name, raw, want string
}{
{
name: "simple key-value",
raw: "timeMin=2026-04-01&maxResults=10",
want: `{"maxResults":"10","timeMin":"2026-04-01"}`,
},
{
name: "with special chars",
raw: "timeMin=2026-04-01T14:00:00+08:00&showDeleted=false",
want: `{"showDeleted":"false","timeMin":"2026-04-01T14:00:00+08:00"}`,
},
{
name: "empty value skipped",
raw: "nextToken=&syncToken=abc",
want: `{"syncToken":"abc"}`,
},
{
name: "all empty",
raw: "nextToken=&syncToken=",
want: "{}",
},
{
name: "empty string",
raw: "",
want: "{}",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseQueryStringToJSON(tt.raw)
if got != tt.want {
t.Errorf("parseQueryStringToJSON(%q) = %s, want %s", tt.raw, got, tt.want)
}
})
}
}
func TestRunAPI_QueryStringBlocked(t *testing.T) {
t.Parallel()
gf := &GlobalFlags{}
cmd := newAPICommand(gf)
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"GET", "/v1.0/calendar/users/me/events?timeMin=2026-04-01&maxResults=10"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error when path contains query string, got nil")
}
errMsg := stderr.String()
if !strings.Contains(errMsg, "--params") {
t.Errorf("expected --params hint in error, got: %s", errMsg)
}
if !strings.Contains(errMsg, "maxResults") {
t.Errorf("expected parsed query params in error, got: %s", errMsg)
}
if !strings.Contains(errMsg, "/v1.0/calendar/users/me/events") {
t.Errorf("expected clean path in suggestion, got: %s", errMsg)
}
}
func TestRunAPI_NoErrorWithoutQueryString(t *testing.T) {
t.Parallel()
gf := &GlobalFlags{}
cmd := newAPICommand(gf)
var stderr bytes.Buffer
cmd.SetErr(&stderr)
cmd.SetOut(&bytes.Buffer{})
cmd.SetArgs([]string{"GET", "/v1.0/contact/users/me"})
err := cmd.Execute()
errMsg := stderr.String()
if strings.Contains(errMsg, "查询参数") {
t.Errorf("should not reject path without query string, got: %s", errMsg)
}
_ = err
}
+6 -1
View File
@@ -198,9 +198,14 @@ func newAuthLogoutCommand() *cobra.Command {
if err := authpkg.DeleteTokenData(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
}
// Clean up associated client secret from keychain
// Clean up associated client secret and app token from keychain
if storedClientID != "" {
_ = authpkg.DeleteClientSecret(storedClientID)
_ = authpkg.DeleteAppTokenData(storedClientID)
}
// Also try cleaning app token using appKey from app config
if appKey, _ := authpkg.ResolveAppCredentials(configDir); appKey != "" && appKey != storedClientID {
_ = authpkg.DeleteAppTokenData(appKey)
}
// Clean up app credentials (app.json + keychain secret)
_ = authpkg.DeleteAppConfig(configDir)
-47
View File
@@ -15,9 +15,6 @@ package app
import (
"context"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/authretry"
)
// authRetryingKey marks a context that has already attempted one
@@ -37,47 +34,3 @@ func IsAuthRetrying(ctx context.Context) bool {
v, _ := ctx.Value(authRetryingKey).(bool)
return v
}
// withAuthRetrying returns a child context flagged as "already retried once"
// so the runner does not enter an infinite refresh loop if the second attempt
// also returns AuthRefreshRequired.
func withAuthRetrying(ctx context.Context) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, authRetryingKey, true)
}
// handleAuthRefreshRequired performs a one-shot ForceRefresh using the active
// configDir and re-runs the invocation through the supplied runner. It must
// only be called when the runner has observed an *authretry.AuthRefreshRequired
// from an edition hook (ClassifyToolResult / OnAuthError).
//
// Behaviour rules — all three matter for safety:
// 1. If the context is already flagged via IsAuthRetrying, this returns
// refresh.Cause unchanged. No further refresh attempts, no recursion.
// 2. If ForceRefresh fails (e.g. refresh_token also expired), this returns
// refresh.Cause so the user sees the original auth diagnostic, not an
// internal "force refresh failed" message.
// 3. On successful refresh, this resets the per-process token cache and
// re-runs the invocation with withAuthRetrying applied so a second
// refresh request from the overlay degrades gracefully to "show the
// original error".
func handleAuthRefreshRequired(
ctx context.Context,
r executor.Runner,
invocation executor.Invocation,
refresh *authretry.AuthRefreshRequired,
) (executor.Result, error) {
if refresh == nil {
return executor.Result{}, nil
}
if IsAuthRetrying(ctx) {
return executor.Result{}, refresh.Cause
}
if _, err := ForceRefreshAccessToken(ctx, defaultConfigDir()); err != nil {
return executor.Result{}, refresh.Cause
}
ResetRuntimeTokenCache()
return r.Run(withAuthRetrying(ctx), invocation)
}
+121 -9
View File
@@ -14,10 +14,13 @@
package app
import (
"net"
"net/url"
"os"
"strings"
"sync"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
@@ -38,6 +41,57 @@ var legacyDirectRuntimeAliases = map[string]string{
"dingtalk-ai-sincere-hire": "ai-sincere-hire",
}
const (
defaultPATProductID = "pat"
defaultPATDisplayName = "行为授权"
defaultPATServerID = "abc3c880fb90f04b52d1426aaf093766e5fc9ec38411688cbb74df42a584d374"
)
func defaultPATServerDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Key: defaultPATProductID,
DisplayName: defaultPATDisplayName,
Endpoint: defaultPATMCPEndpoint(),
CLI: market.CLIOverlay{
ID: defaultPATProductID,
Command: defaultPATProductID,
Prefixes: []string{defaultPATProductID},
},
}
}
func defaultPATMCPEndpoint() string {
return defaultPATGatewayBaseURL() + "/server/" + defaultPATServerID
}
func defaultPATGatewayBaseURL() string {
raw := strings.TrimSpace(authpkg.GetMCPBaseURL())
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return strings.TrimRight(raw, "/")
}
host := parsed.Hostname()
switch {
case host == "mcp.dingtalk.com":
host = "mcp-gw.dingtalk.com"
case strings.HasPrefix(host, "pre-mcp."):
host = strings.Replace(host, "pre-mcp.", "pre-mcp-gw.", 1)
case strings.HasPrefix(host, "mcp."):
host = strings.Replace(host, "mcp.", "mcp-gw.", 1)
}
if port := parsed.Port(); port != "" {
parsed.Host = net.JoinHostPort(host, port)
} else {
parsed.Host = host
}
parsed.Path = strings.TrimRight(parsed.Path, "/")
parsed.RawQuery = ""
parsed.Fragment = ""
return strings.TrimRight(parsed.String(), "/")
}
// SetDynamicServers injects server data discovered from servers.json.
// All product endpoints are resolved dynamically from this data.
func SetDynamicServers(servers []market.ServerDescriptor) {
@@ -48,6 +102,7 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
products := make(map[string]bool)
aliases := make(map[string]string)
toolEndpoints := make(map[string]string)
registerDynamicServer(defaultPATServerDescriptor(), endpoints, products, aliases, toolEndpoints)
for _, server := range servers {
if server.CLI.Skip {
continue
@@ -102,6 +157,47 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
dynamicToolEndpoints = toolEndpoints
}
func registerDynamicServer(server market.ServerDescriptor, endpoints map[string]string, products map[string]bool, aliases map[string]string, toolEndpoints map[string]string) {
if server.CLI.Skip {
return
}
id := strings.TrimSpace(server.CLI.ID)
endpoint := strings.TrimSpace(server.Endpoint)
if id != "" && endpoint != "" {
endpoints[id] = endpoint
products[id] = true
}
cmd := strings.TrimSpace(server.CLI.Command)
if cmd != "" && cmd != id && endpoint != "" {
endpoints[cmd] = endpoint
products[cmd] = true
}
for _, alias := range server.CLI.Aliases {
alias = strings.TrimSpace(alias)
if alias != "" && endpoint != "" {
endpoints[alias] = endpoint
products[alias] = true
// Build alias -> CLI.ID mapping.
aliases[alias] = id
}
}
// Build tool -> endpoint mapping from CLI tools and overrides.
if endpoint != "" {
for _, tool := range server.CLI.Tools {
toolName := strings.TrimSpace(tool.Name)
if toolName != "" {
toolEndpoints[toolName] = endpoint
}
}
for toolName := range server.CLI.ToolOverrides {
toolName = strings.TrimSpace(toolName)
if toolName != "" {
toolEndpoints[toolName] = endpoint
}
}
}
}
func shouldUseDirectRuntime(invocation executor.Invocation) bool {
if strings.TrimSpace(os.Getenv(cli.CatalogFixtureEnv)) != "" {
return false
@@ -131,14 +227,11 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
te := dynamicToolEndpoints
dynamicMu.RUnlock()
// Priority 1: tool-level endpoint (resolves multi-endpoint products).
if tool := strings.TrimSpace(toolName); tool != "" && te != nil {
if endpoint, ok := te[tool]; ok {
return endpoint, true
}
}
// Priority 2: product-level endpoint.
// Priority 1: product-level endpoint.
// When the caller already knows the productID (e.g. "drive"), the product
// endpoint is authoritative. This prevents cross-product tool name
// collisions (e.g. both "drive" and "doc" register "create_folder") from
// routing the request to the wrong MCP server. See issue #219.
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
if candidate == "" {
continue
@@ -149,6 +242,24 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
}
}
}
// Priority 2: tool-level endpoint (fallback for unknown productID).
// This path is used when the caller does not know the productID but has a
// tool name, e.g. in helper invocations or plugin routes where only the
// tool name is available.
if tool := strings.TrimSpace(toolName); tool != "" && te != nil {
if endpoint, ok := te[tool]; ok {
return endpoint, true
}
}
// Priority 3: built-in PAT fallback for cold-start paths that run before
// discovery/plugin registration has populated the dynamic registry.
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
if candidate == defaultPATProductID {
return defaultPATMCPEndpoint(), true
}
}
return "", false
}
@@ -158,7 +269,8 @@ func DirectRuntimeProductIDs() map[string]bool {
dynamicMu.RLock()
dp := dynamicProducts
dynamicMu.RUnlock()
ids := make(map[string]bool, len(dp))
ids := make(map[string]bool, len(dp)+1)
ids[defaultPATProductID] = true
for key := range dp {
ids[key] = true
}
@@ -181,3 +181,114 @@ func TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint(t *testing.
})
}
}
// --- Issue #219 regression tests: cross-product tool name collision ---
//
// When two different products register tools with the same name (e.g. drive
// and doc both have "create_folder"), the product-level endpoint must win
// when the caller already knows the productID. Otherwise the tool-level map
// (last-writer-wins) routes the invocation to the wrong MCP server.
const (
testDriveEndpoint = "https://mcp-gw.dingtalk.com/server/drive-hash"
testDocEndpoint = "https://mcp-gw.dingtalk.com/server/doc-hash"
)
func driveDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testDriveEndpoint,
CLI: market.CLIOverlay{
ID: "drive",
Command: "drive",
ToolOverrides: map[string]market.CLIToolOverride{
"create_folder": {CLIName: "mkdir"},
"list_files": {CLIName: "list"},
"download_file": {CLIName: "download"},
"get_upload_info": {CLIName: "upload-info"},
},
},
}
}
func docDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testDocEndpoint,
CLI: market.CLIOverlay{
ID: "doc",
Command: "doc",
ToolOverrides: map[string]market.CLIToolOverride{
"create_folder": {CLIName: "create", Group: "folder"},
"download_file": {CLIName: "download"},
"search_documents": {CLIName: "search"},
"list_nodes": {CLIName: "list"},
},
},
}
}
// TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel verifies
// that when productID is known and has a registered endpoint, the product-level
// endpoint is used even if the tool-level map points to a different server
// (due to same-name tool collision). This is the core fix for issue #219.
func TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel(t *testing.T) {
tests := []struct {
name string
servers []market.ServerDescriptor
}{
{
name: "drive first, doc second",
servers: []market.ServerDescriptor{driveDescriptor(), docDescriptor()},
},
{
name: "doc first, drive second",
servers: []market.ServerDescriptor{docDescriptor(), driveDescriptor()},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
withCleanDynamicRegistry(t)
SetDynamicServers(tc.servers)
// Drive tools must always route to drive's endpoint regardless of
// registration order — productID "drive" is known.
assertEndpoint(t, "drive", "create_folder", testDriveEndpoint)
assertEndpoint(t, "drive", "download_file", testDriveEndpoint)
assertEndpoint(t, "drive", "list_files", testDriveEndpoint)
assertEndpoint(t, "drive", "get_upload_info", testDriveEndpoint)
// Doc tools must always route to doc's endpoint.
assertEndpoint(t, "doc", "create_folder", testDocEndpoint)
assertEndpoint(t, "doc", "download_file", testDocEndpoint)
assertEndpoint(t, "doc", "search_documents", testDocEndpoint)
assertEndpoint(t, "doc", "list_nodes", testDocEndpoint)
// Product-level fallback (no tool name) still works.
assertEndpoint(t, "drive", "", testDriveEndpoint)
assertEndpoint(t, "doc", "", testDocEndpoint)
})
}
}
// TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown verifies that
// tool-level routing still works as a fallback when productID is empty or has
// no registered endpoint (the original design intent for tool-level Priority 1).
func TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown(t *testing.T) {
withCleanDynamicRegistry(t)
SetDynamicServers([]market.ServerDescriptor{driveDescriptor(), docDescriptor()})
// When productID is empty, tool-level endpoint is the only option.
// The actual endpoint depends on registration order (last-writer-wins),
// but the lookup must succeed.
endpoint, ok := directRuntimeEndpoint("", "create_folder")
if !ok {
t.Fatal("directRuntimeEndpoint(\"\", \"create_folder\") returned ok=false, want ok=true")
}
if endpoint != testDriveEndpoint && endpoint != testDocEndpoint {
t.Fatalf("directRuntimeEndpoint(\"\", \"create_folder\") = %q, want one of drive/doc endpoints", endpoint)
}
// Unique tools (no collision) still resolve via tool-level.
assertEndpoint(t, "", "search_documents", testDocEndpoint)
assertEndpoint(t, "", "get_upload_info", testDriveEndpoint)
}
+69 -1
View File
@@ -1,6 +1,74 @@
package app
import "testing"
import (
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
)
func TestDefaultPATServerDescriptorUsesBehaviorAuthorizationName(t *testing.T) {
server := defaultPATServerDescriptor()
if server.CLI.ID != "pat" {
t.Fatalf("default PAT server id = %q, want pat", server.CLI.ID)
}
if server.DisplayName != "行为授权" {
t.Fatalf("default PAT server display name = %q, want 行为授权", server.DisplayName)
}
if server.Endpoint != defaultPATMCPEndpoint() {
t.Fatalf("default PAT server endpoint = %q, want %q", server.Endpoint, defaultPATMCPEndpoint())
}
}
func TestDirectRuntimeProductIDsIncludesDefaultPAT(t *testing.T) {
dynamicMu.Lock()
previousProducts := dynamicProducts
dynamicProducts = nil
dynamicMu.Unlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicProducts = previousProducts
dynamicMu.Unlock()
})
ids := DirectRuntimeProductIDs()
if !ids["pat"] {
t.Fatalf("DirectRuntimeProductIDs() missing default pat product: %#v", ids)
}
}
func TestDirectRuntimeEndpoint_DefaultPATFallbackWhenRegistryMissing(t *testing.T) {
withCleanDynamicRegistry(t)
assertEndpoint(t, "pat", "", defaultPATMCPEndpoint())
}
func TestDirectRuntimeEndpoint_DefaultPATFallbackUsesConfiguredMCPBaseURL(t *testing.T) {
withCleanDynamicRegistry(t)
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "mcp_url"), []byte("http://127.0.0.1:54321/base"), 0o600); err != nil {
t.Fatalf("WriteFile(mcp_url) error = %v", err)
}
t.Setenv("DWS_CONFIG_DIR", tmpDir)
assertEndpoint(t, "pat", "", "http://127.0.0.1:54321/base/server/"+defaultPATServerID)
}
func TestDirectRuntimeEndpoint_PATDiscoveryOverrideWinsOverBuiltInFallback(t *testing.T) {
withCleanDynamicRegistry(t)
customEndpoint := "https://example.com/server/custom-pat"
SetDynamicServers([]market.ServerDescriptor{
{
Endpoint: customEndpoint,
CLI: market.CLIOverlay{
ID: "pat",
Command: "pat",
},
},
})
assertEndpoint(t, "pat", "", customEndpoint)
}
func TestNormalizeDirectRuntimeProductIDPreservesLegacyHiddenVendorRouting(t *testing.T) {
dynamicMu.Lock()
-7
View File
@@ -429,10 +429,3 @@ func printPerfReportSummary(w io.Writer, report *PerfReport) {
fmt.Fprintf(w, " %-25s ─────────\n", "─────────────────────────")
fmt.Fprintf(w, " %-25s %dms (框架开销 %dms)\n", "总耗时", report.TotalMs, report.OverheadMs)
}
func formatLocalTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Local().Format("2006-01-02 15:04")
}
+11 -55
View File
@@ -29,6 +29,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/editionmerge"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
@@ -123,13 +124,10 @@ func injectStaticServers(servers []edition.ServerInfo) {
// Tests may override discoveryBaseURLOverride to redirect to a local server;
// in that case the registry cache is always bypassed.
// editionPartition returns the cache partition for the active edition.
// Each edition gets its own partition to prevent cross-edition data leakage.
// Thin wrapper around config.EditionPartition; kept so the many existing
// call sites in internal/app don't need to thread edition.Get() everywhere.
func editionPartition() string {
name := edition.Get().Name
if name == "" || name == "open" {
return config.DefaultPartition
}
return name + "/default"
return config.EditionPartition(edition.Get().Name)
}
// discoveryTraceEnabled reports whether the user asked for discovery-path diagnostics.
@@ -265,8 +263,8 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
if fn := edition.Get().FallbackServers; fn != nil {
if fb := fn(); len(fb) > 0 {
slog.Debug("loadDynamicCommands: using FallbackServers", "count", len(fb))
descriptors := fallbackToDescriptors(fb)
descriptors = mergeSupplementServers(descriptors)
descriptors := editionmerge.FallbackToDescriptors(fb)
descriptors = editionmerge.MergeSupplement(descriptors)
SetDynamicServers(descriptors)
return nil
}
@@ -275,7 +273,7 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
}
// Merge edition-specific supplement servers (not in Market).
servers = mergeSupplementServers(servers)
servers = editionmerge.MergeSupplement(servers)
// Inject dynamic server data for endpoint resolution
SetDynamicServers(servers)
@@ -545,49 +543,7 @@ func mergeTopLevelCommands(commands []*cobra.Command) []*cobra.Command {
return out
}
// mergeSupplementServers appends edition-specific servers (not in Market)
// into the discovery result. Existing IDs from Market/cache take precedence.
func mergeSupplementServers(servers []market.ServerDescriptor) []market.ServerDescriptor {
fn := edition.Get().SupplementServers
if fn == nil {
return servers
}
existing := make(map[string]bool, len(servers))
for _, s := range servers {
existing[s.CLI.ID] = true
existing[s.Key] = true
}
for _, sup := range fn() {
if !existing[sup.ID] {
servers = append(servers, market.ServerDescriptor{
Key: sup.ID,
DisplayName: sup.Name,
Endpoint: sup.Endpoint,
CLI: market.CLIOverlay{
ID: sup.ID,
Command: sup.ID,
Prefixes: sup.Prefixes,
},
})
}
}
return servers
}
// fallbackToDescriptors converts edition.ServerInfo into market.ServerDescriptor.
func fallbackToDescriptors(servers []edition.ServerInfo) []market.ServerDescriptor {
descriptors := make([]market.ServerDescriptor, 0, len(servers))
for _, s := range servers {
descriptors = append(descriptors, market.ServerDescriptor{
Key: s.ID,
DisplayName: s.Name,
Endpoint: s.Endpoint,
CLI: market.CLIOverlay{
ID: s.ID,
Command: s.ID,
Prefixes: s.Prefixes,
},
})
}
return descriptors
}
// mergeSupplementServers / fallbackToDescriptors have moved to
// internal/editionmerge so that both internal/cli and internal/app can
// apply the edition's SupplementServers / FallbackServers hooks against
// the same discovery pipeline (command tree + runtime catalog).
+59
View File
@@ -16,6 +16,7 @@ package app
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/spf13/cobra"
)
@@ -143,6 +144,64 @@ func TestPickCommands_EmptyDynamicPreservesHelpers(t *testing.T) {
}
}
// TestPickCommands_HelperGroupShadowsDynamicLeaf simulates the issue #164
// shape mismatch: the discovery envelope publishes `chat group members` as
// a LEAF (the get_group_members tool exposed at that CLI path), while the
// hardcoded helper has restructured `members` into a GROUP container with
// `list / add / remove / add-bot` subcommands. The helper group carries the
// preferLegacyLeaf priority annotation, so it must replace the dynamic leaf
// and surface its subtree — otherwise `dws chat group members list` is
// unreachable and the user-visible regression in #164 stays.
func TestPickCommands_HelperGroupShadowsDynamicLeaf(t *testing.T) {
dynMembers := &cobra.Command{Use: "members", Run: func(*cobra.Command, []string) {}}
dynMembers.Flags().String("id", "", "")
dynGroup := &cobra.Command{Use: "group"}
dynGroup.AddCommand(dynMembers)
dyn := &cobra.Command{Use: "chat"}
dyn.AddCommand(dynGroup)
hlpList := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
hlpList.Flags().String("id", "", "")
hlpAdd := &cobra.Command{Use: "add", Run: func(*cobra.Command, []string) {}}
hlpRemove := &cobra.Command{Use: "remove", Run: func(*cobra.Command, []string) {}}
hlpMembers := &cobra.Command{Use: "members"}
hlpMembers.AddCommand(hlpList, hlpAdd, hlpRemove)
cobracmd.SetOverridePriority(hlpMembers, 100)
hlpGroup := &cobra.Command{Use: "group"}
hlpGroup.AddCommand(hlpMembers)
hlp := &cobra.Command{Use: "chat"}
hlp.AddCommand(hlpGroup)
got := pickCommands([]*cobra.Command{dyn}, []*cobra.Command{hlp})
if len(got) != 1 || got[0] != dyn {
t.Fatalf("got %v, want [dyn]", got)
}
// Locate the (potentially replaced) members node under chat.group.
var members *cobra.Command
for _, c := range dynGroup.Commands() {
if c.Name() == "members" {
members = c
break
}
}
if members == nil {
t.Fatalf("members node missing under dyn.chat.group after merge")
}
want := map[string]bool{"list": false, "add": false, "remove": false}
for _, sub := range members.Commands() {
if _, ok := want[sub.Name()]; ok {
want[sub.Name()] = true
}
}
for name, seen := range want {
if !seen {
t.Errorf("expected `chat group members %s` after merge, missing", name)
}
}
}
// TestPickCommands_NilsAreSkipped guards against nil entries sneaking in from
// a misbehaving factory.
func TestPickCommands_NilsAreSkipped(t *testing.T) {
@@ -0,0 +1,64 @@
// 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 (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// TestEditionPartition_SingleSourceOfTruth is the regression test that
// specifically targets the original bug: internal/app.loadDynamicCommands
// was computing its partition one way (editionPartition() →
// "wukong/default") while internal/cli.EnvironmentLoader was hardcoding
// config.DefaultPartition ("default/default"). This meant runtime endpoint
// resolution and command-tree generation read different cache files, and
// under gray-release the two partitions carried disjoint product lists —
// the historical root cause of `dws conference meeting create` failing
// while `dws todo task list` succeeded on the same host.
//
// Keeping both sides funneled through config.EditionPartition is the
// central invariant the fix enforces. If this test ever regresses, the
// two-partition split almost certainly came back.
func TestEditionPartition_SingleSourceOfTruth(t *testing.T) {
t.Cleanup(func() { edition.Override(&edition.Hooks{}) })
cases := []struct {
name string
edition string
want string
}{
{"open edition falls through to default/default", "", config.DefaultPartition},
{"explicit open edition remains default", "open", config.DefaultPartition},
{"wukong overlay uses wukong/default", "wukong", "wukong/default"},
{"custom edition is namespaced", "internal-lab", "internal-lab/default"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
edition.Override(&edition.Hooks{Name: tc.edition})
legacy := editionPartition()
shared := config.EditionPartition(edition.Get().Name)
if legacy != shared {
t.Fatalf("editionPartition()=%q, config.EditionPartition()=%q — partition split regressed for edition %q", legacy, shared, tc.edition)
}
if legacy != tc.want {
t.Fatalf("editionPartition()=%q, want %q for edition %q", legacy, tc.want, tc.edition)
}
})
}
}
+190 -32
View File
@@ -32,6 +32,7 @@ import (
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/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/fatih/color"
)
@@ -43,8 +44,12 @@ const (
// PatAuthPollInterval is how often we poll to check if the user has
// completed authorization.
PatAuthPollInterval = 5 * time.Second
patScopeAuthRequiredCode = "PAT_SCOPE_AUTH_REQUIRED"
)
var openBrowserFunc = tryOpenBrowser
// PatScopeError holds information about a missing PAT scope.
type PatScopeError struct {
OriginalError string
@@ -182,21 +187,50 @@ func PrintPatAuthError(w io.Writer, scopeErr *PatScopeError) {
// 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,
},
fmt.Fprintln(w, buildPATScopeJSON(scopeErr, authpkg.HostOwnsPATFlow()))
}
func wantsStructuredPATOutput(r *runtimeRunner) bool {
if r == nil || r.globalFlags == nil {
return false
}
if scopeErr.MissingScope != "" {
payload["missing_scope"] = scopeErr.MissingScope
return strings.EqualFold(strings.TrimSpace(r.globalFlags.Format), "json")
}
func wantsStructuredPATOutputFromRunner(runner executor.Runner) bool {
rr, ok := runner.(*runtimeRunner)
if !ok {
return false
}
return wantsStructuredPATOutput(rr)
}
func currentPATOpenBrowser(configDir string) bool {
return pat.EffectiveOpenBrowser(configDir)
}
func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
if strings.TrimSpace(raw) == "" {
return raw
}
data, _ := json.MarshalIndent(payload, "", " ")
fmt.Fprintln(w, string(data))
var payload map[string]any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return raw
}
data, ok := payload["data"].(map[string]any)
if !ok || data == nil {
data = map[string]any{}
payload["data"] = data
}
data["openBrowser"] = openBrowser
encoded, err := json.Marshal(payload)
if err != nil {
return raw
}
return string(encoded)
}
// WaitForPatAuthorization polls until the user completes authorization or timeout.
@@ -258,6 +292,19 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
// 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) {
hostOwnedPAT := authpkg.HostOwnsPATFlow()
slog.Debug("pat.host_owned_decision",
"site", "retryWithPatAuthRetry",
"hostOwned", hostOwnedPAT,
"agentCodeEnvSet", os.Getenv(authpkg.AgentCodeEnv) != "",
)
if hostOwnedPAT {
return executor.Result{}, &apperrors.PATError{RawJSON: buildPATScopeJSON(scopeErr, true)}
}
if wantsStructuredPATOutputFromRunner(runner) {
return executor.Result{}, &apperrors.PATError{RawJSON: buildPATScopeJSON(scopeErr, false)}
}
// Print the PAT error in human-readable format
PrintPatAuthError(output, scopeErr)
@@ -305,6 +352,32 @@ func IsPatRetrying(ctx context.Context) bool {
return v
}
func openPATAuthorizationURI(rawURI string) error {
if rawURI == "" {
// Defensive guard for future callers. The current call site already
// checks for a non-empty PAT URI before invoking this helper.
return nil
}
// The PAT service returns the complete authorization URL. Treat it as an
// opaque string and open it verbatim instead of parsing/rebuilding it
// locally, because required parameters may live in query, hash, or
// fragment sections.
return openBrowserFunc(rawURI)
}
func printPATPollDebugResponse(output io.Writer, statusCode int, body []byte) {
if os.Getenv("DWS_DEBUG_PAT_POLL") == "" {
return
}
trimmed := strings.TrimSpace(string(body))
if trimmed == "" {
trimmed = "<empty body>"
}
fmt.Fprintln(output)
fmt.Fprintf(output, " ℹ PAT 轮询接口返回原文 (HTTP %d):\n", statusCode)
fmt.Fprintf(output, " %s\n", trimmed)
}
// 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,
@@ -338,9 +411,17 @@ func handlePatAuthCheck(
"flowId", patData.Data.FlowID,
"hasSecret", patData.Data.ClientSecret != "",
)
hostOwnedPAT := authpkg.HostOwnsPATFlow()
openBrowser := currentPATOpenBrowser(configDir)
slog.Debug("pat.host_owned_decision",
"site", "handlePatAuthCheck",
"hostOwned", hostOwnedPAT,
"agentCodeEnvSet", os.Getenv(authpkg.AgentCodeEnv) != "",
)
// Inject clientId/clientSecret from PAT response as runtime credentials
// so that subsequent device flow auth uses the server-assigned app identity.
var appCfg *authpkg.AppConfig
if patData.Data.ClientID != "" {
if patData.Data.ClientSecret != "" {
// When both clientId and clientSecret are provided, use direct mode
@@ -353,19 +434,32 @@ func handlePatAuthCheck(
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,
}
// Persist only after an explicit APPROVED result below. Raw PAT
// interceptions (host-owned / json / empty-flow pass-through) must not
// rewrite the shared ~/.dws/app.json state for unrelated shells or agents.
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)
}
// In host-controlled PAT mode (driven solely by DINGTALK_DWS_AGENTCODE),
// or when flowId is absent, the CLI returns machine-readable JSON to
// stderr and leaves UI/polling/retry to the host. `claw-type` is NOT
// used for this decision — it is only forwarded on the wire via
// edition.MergeHeaders and surfaced in hostControl for traceability.
if hostOwnedPAT || patData.Data.FlowID == "" {
if hostOwnedPAT {
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorForHostControl(patErr.RawJSON)}
}
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, openBrowser)}
}
if wantsStructuredPATOutput(r) {
if openBrowser && patData.Data.URI != "" {
_ = openBrowserFunc(patData.Data.URI)
}
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, openBrowser)}
}
bold := color.New(color.Bold).SprintFunc()
@@ -382,14 +476,9 @@ func handlePatAuthCheck(
}
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
if openBrowser {
_ = openPATAuthorizationURI(patData.Data.URI)
}
}
// Poll the device flow status until user authorizes, rejects, or timeout.
@@ -411,6 +500,13 @@ func handlePatAuthCheck(
fmt.Fprintf(output, "%s %s\n", greenFn("✓"), bold("授权成功!"))
fmt.Fprintln(output)
if appCfg != nil {
if err := authpkg.SaveAppConfig(configDir, appCfg); err != nil {
slog.Warn("failed to persist approved app config from PAT", "error", err)
fmt.Fprintf(output, " \u26a0 保存应用配置失败: %v (下次启动可能需要重新授权)\n", err)
}
}
// Exchange authCode for a fresh access token (mirrors device_flow loginOnce).
if authCode != "" {
slog.Debug("PAT retry: exchanging authCode for token", "hasCode", true)
@@ -475,6 +571,66 @@ func handlePatAuthCheck(
}
}
func enrichPATErrorForHostControl(raw string) string {
if strings.TrimSpace(raw) == "" {
return raw
}
var payload map[string]any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return raw
}
// Route back through the classifier so host-owned active retry emits the
// exact same PAT JSON shape as passive classification.
if patErr := apperrors.ClassifyPatAuthCheck(payload); patErr != nil {
return patErr.RawJSON
}
apperrors.ApplyHostMutations(payload)
// stderr JSON MUST be single-line.
encoded, err := json.Marshal(payload)
if err != nil {
return raw
}
return string(encoded)
}
// buildPATScopeJSON renders the PAT_SCOPE_AUTH_REQUIRED stderr payload.
// includeHostControl=true follows the standard host-owned/CLI-owned split
// (data.hostControl is injected only if HostControlBlock is non-nil).
// includeHostControl=false is an explicit override used by the CLI-owned
// branch so that any env-mode misconfiguration cannot leak a host-owned
// contract into stderr.
func buildPATScopeJSON(scopeErr *PatScopeError, includeHostControl bool) string {
data := map[string]any{
"identity": scopeErr.Identity,
"errorType": scopeErr.ErrorType,
"message": scopeErr.Message,
"hint": scopeErr.Hint,
"missingScope": scopeErr.MissingScope,
"openBrowser": apperrors.PATOpenBrowserValue(),
}
if includeHostControl {
if hostControl := apperrors.HostControlBlock(); hostControl != nil {
data["hostControl"] = hostControl
}
}
payload := map[string]any{
"success": false,
"code": patScopeAuthRequiredCode,
"data": data,
}
// stderr JSON MUST be single-line.
b, err := json.Marshal(payload)
if err != nil {
return `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED"}`
}
return string(b)
}
// 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).
@@ -537,23 +693,25 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out
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))
printPATPollDebugResponse(output, resp.StatusCode, bodyBytes)
continue
}
status := authpkg.ParseDeviceFlowStatus(pollResp.Data.Status, pollResp.Success)
pollData := pollResp.EffectiveData()
status := authpkg.ParseDeviceFlowStatus(pollData.Status, pollResp.Success)
switch status {
case authpkg.StatusApproved:
fmt.Fprintln(output) // clear the polling line
return status, pollResp.Data.AuthCode, nil
return status, pollData.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)
printPATPollDebugResponse(output, resp.StatusCode, bodyBytes)
return status, "", nil
}
}
+607 -21
View File
@@ -29,6 +29,7 @@ import (
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/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
)
func TestIsPatScopeError_MissingScope(t *testing.T) {
@@ -86,21 +87,6 @@ func TestIsPatScopeError_InsufficientScope(t *testing.T) {
}
}
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{
@@ -142,7 +128,7 @@ func TestPrintPatAuthError_HumanReadable(t *testing.T) {
}
func TestPrintPatAuthJSON_MachineReadable(t *testing.T) {
t.Parallel()
t.Setenv(authpkg.AgentCodeEnv, "")
var buf strings.Builder
scopeErr := &PatScopeError{
Identity: "user",
@@ -153,12 +139,28 @@ func TestPrintPatAuthJSON_MachineReadable(t *testing.T) {
}
PrintPatAuthJSON(&buf, scopeErr)
// The payload is required to be single-line; assert by parsing the JSON
// rather than by matching pretty-printed substrings.
output := buf.String()
if !strings.Contains(output, `"ok": false`) {
t.Errorf("expected JSON to contain ok: false, got: %s", output)
var parsed map[string]any
if err := json.Unmarshal([]byte(output), &parsed); err != nil {
t.Fatalf("PrintPatAuthJSON must emit directly-parsable JSON: %v\nraw=%s", err, output)
}
if !strings.Contains(output, `"missing_scope": "mail:send"`) {
t.Errorf("expected JSON to contain missing_scope, got: %s", output)
if code, _ := parsed["code"].(string); code != "PAT_SCOPE_AUTH_REQUIRED" {
t.Errorf("code = %q, want PAT_SCOPE_AUTH_REQUIRED", code)
}
data, _ := parsed["data"].(map[string]any)
if data == nil {
t.Fatalf("expected data object, got: %s", output)
}
if got, _ := data["missingScope"].(string); got != "mail:send" {
t.Errorf("missingScope = %q, want mail:send", got)
}
if got, ok := data["openBrowser"].(bool); !ok || !got {
t.Errorf("openBrowser = %#v, want true", data["openBrowser"])
}
if _, ok := data["hostControl"]; ok {
t.Errorf("unexpected data.hostControl in CLI-owned JSON output: %s", output)
}
}
@@ -411,6 +413,84 @@ func TestPollPatDeviceFlow_RedirectSkipped(t *testing.T) {
}
}
func TestPollPatDeviceFlow_UnknownStatusPrintsRawResponse(t *testing.T) {
t.Setenv("DWS_DEBUG_PAT_POLL", "1")
server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{
{Success: true, 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-unknown", configDir, &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status != "" {
t.Fatalf("expected empty unknown status, got %q", status)
}
if authCode != "" {
t.Fatalf("expected empty authCode for unknown status, got %q", authCode)
}
output := buf.String()
if !strings.Contains(output, "PAT 轮询接口返回原文") {
t.Fatalf("expected raw poll response to be printed, got %q", output)
}
if !strings.Contains(output, `"status":""`) {
t.Fatalf("expected raw poll body in output, got %q", output)
}
}
func TestPollPatDeviceFlow_UnknownStatusHidesRawResponseByDefault(t *testing.T) {
server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{
{Success: true, 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-unknown-default", configDir, &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status != "" {
t.Fatalf("expected empty unknown status, got %q", status)
}
if authCode != "" {
t.Fatalf("expected empty authCode for unknown status, got %q", authCode)
}
output := buf.String()
if strings.Contains(output, "PAT 轮询接口返回原文") {
t.Fatalf("expected raw poll response to stay hidden by default, got %q", output)
}
}
func TestPollPatDeviceFlow_ResultEnvelopeCompatibility(t *testing.T) {
server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{
{Success: true, Result: authpkg.DevicePollData{Status: "PENDING"}},
{Success: true, Result: authpkg.DevicePollData{Status: "APPROVED", AuthCode: "code-from-result"}},
})
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var buf bytes.Buffer
status, authCode, err := pollPatDeviceFlow(ctx, "flow-result", configDir, &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status != "APPROVED" {
t.Fatalf("expected APPROVED, got %q", status)
}
if authCode != "code-from-result" {
t.Fatalf("expected authCode from result envelope, got %q", authCode)
}
}
// ---------------------------------------------------------------------------
// extractPatScopeError edge cases
// ---------------------------------------------------------------------------
@@ -484,6 +564,10 @@ func setupHandlePATServer(t *testing.T, terminalStatus string, authCode string)
}
func makePATErrorJSON(flowID, clientID string) string {
return makePATErrorJSONWithURI(flowID, clientID, "")
}
func makePATErrorJSONWithURI(flowID, clientID, uri string) string {
type patData struct {
Desc string `json:"desc"`
FlowID string `json:"flowId"`
@@ -498,7 +582,7 @@ func makePATErrorJSON(flowID, clientID string) string {
Data: patData{
Desc: "test auth",
FlowID: flowID,
URI: "", // empty to avoid opening browser in test
URI: uri,
ClientID: clientID,
},
}
@@ -507,6 +591,7 @@ func makePATErrorJSON(flowID, clientID string) string {
}
func TestHandlePatAuthCheck_Approved(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
defer server.Close()
@@ -539,6 +624,9 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
if !retryHasKey {
t.Fatal("expected retry context to have patRetryingKey")
}
if _, err := os.Stat(filepath.Join(configDir, "app.json")); err != nil {
t.Fatalf("expected approved PAT flow to persist app.json, stat error = %v", err)
}
// 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)
@@ -546,6 +634,7 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
}
func TestHandlePatAuthCheck_Rejected(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
server, configDir := setupHandlePATServer(t, "REJECTED", "")
defer server.Close()
@@ -574,7 +663,132 @@ func TestHandlePatAuthCheck_Rejected(t *testing.T) {
}
}
func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", tmpDir)
// Host-owned decision: driven ONLY by DINGTALK_DWS_AGENTCODE.
// DINGTALK_AGENT is set to demonstrate it does NOT leak into
// hostControl.clawType — the open-source build pins that to the
// literal edition.DefaultOSSClawType value ("openClaw").
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
t.Setenv("DINGTALK_AGENT", "sales-copilot")
mock := &mockRunner{
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
t.Fatal("runner should not be called in host-controlled PAT mode")
return executor.Result{}, nil
},
}
runner := &runtimeRunner{fallback: mock}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("flow-host", "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 in host-controlled mode")
}
patOut, ok := err.(*apperrors.PATError)
if !ok {
t.Fatalf("expected *PATError, got %T: %v", err, err)
}
if got := strings.TrimSpace(buf.String()); got != "" {
t.Fatalf("expected no human-readable output in host mode, got %q", got)
}
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
t.Fatalf("host-owned PAT must not persist shared app.json, stat error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
t.Fatalf("json.Unmarshal(host PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
}
data, _ := payload["data"].(map[string]any)
if got, _ := data["flowId"].(string); got != "flow-host" {
t.Fatalf("data.flowId = %q, want flow-host", got)
}
hostControl, _ := data["hostControl"].(map[string]any)
if got, _ := hostControl["clawType"].(string); got != "openClaw" {
t.Fatalf("hostControl.clawType = %q, want openClaw (hard-wired by open-source edition)", got)
}
if got, _ := hostControl["callbackOwner"].(string); got != "host" {
t.Fatalf("hostControl.callbackOwner = %q, want host", got)
}
if _, ok := data["callbacks"]; ok {
t.Fatalf("unexpected callbacks contract in host-controlled PAT payload: %#v", data["callbacks"])
}
if _, ok := payload["_meta"]; ok {
t.Fatalf("unexpected _meta contract in host-controlled PAT payload: %#v", payload["_meta"])
}
if strings.Contains(patOut.RawJSON, `"pat","callback"`) {
t.Fatalf("host PAT payload should not advertise dws pat callback argv: %s", patOut.RawJSON)
}
}
func TestHandlePatAuthCheck_HostControlledEmptyFlowID_StillReturnsContract(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", tmpDir)
t.Setenv(authpkg.AgentCodeEnv, "agt-support")
t.Setenv("DINGTALK_AGENT", "customer-support")
mock := &mockRunner{
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
t.Fatal("runner should not be called in host-controlled PAT mode")
return executor.Result{}, nil
},
}
runner := &runtimeRunner{fallback: mock}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("", "test-client-id")}
var buf bytes.Buffer
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
CanonicalProduct: "test",
Tool: "test_tool",
}, patErr, tmpDir, &buf)
if err == nil {
t.Fatal("expected PATError in host-controlled mode")
}
if got := strings.TrimSpace(buf.String()); got != "" {
t.Fatalf("expected no human-readable output in host mode, got %q", got)
}
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
t.Fatalf("host-owned PAT must not persist shared app.json, stat error = %v", err)
}
patOut, ok := err.(*apperrors.PATError)
if !ok {
t.Fatalf("expected *PATError, got %T: %v", err, err)
}
var payload map[string]any
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
t.Fatalf("json.Unmarshal(host PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
}
data, _ := payload["data"].(map[string]any)
hostControl, _ := data["hostControl"].(map[string]any)
if got, _ := hostControl["callbackOwner"].(string); got != "host" {
t.Fatalf("hostControl.callbackOwner = %q, want host", got)
}
if _, ok := data["callbacks"]; ok {
t.Fatalf("unexpected callbacks contract when flowId is absent: %#v", data["callbacks"])
}
if _, ok := payload["_meta"]; ok {
t.Fatalf("unexpected _meta contract when flowId is absent: %#v", payload["_meta"])
}
if strings.Contains(patOut.RawJSON, `"pat","callback"`) {
t.Fatalf("host PAT payload should not advertise dws pat callback argv: %s", patOut.RawJSON)
}
}
func TestHandlePatAuthCheck_EmptyFlowID_FallsBackToPATError(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
// No poll server needed — empty flowId means no polling, return PATError directly.
tmpDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", tmpDir)
@@ -603,4 +817,376 @@ func TestHandlePatAuthCheck_EmptyFlowID_FallsBackToPATError(t *testing.T) {
if _, ok := err.(*apperrors.PATError); !ok {
t.Errorf("expected *PATError, got %T: %v", err, err)
}
if got := strings.TrimSpace(buf.String()); got != "" {
t.Fatalf("expected no human-readable output for raw PAT passthrough, got %q", got)
}
}
func TestHandlePatAuthCheck_JSONModeReturnsStructuredPATErrorWithoutRetry(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
tmpDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", tmpDir)
if _, err := pat.SetBrowserPolicy(tmpDir, "", false); err != nil {
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
}
mock := &mockRunner{
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
t.Fatal("runner should not be called in json PAT mode")
return executor.Result{}, nil
},
}
runner := &runtimeRunner{
fallback: mock,
globalFlags: &GlobalFlags{Format: "json"},
}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("flow-json", "test-client-id")}
var buf bytes.Buffer
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
CanonicalProduct: "test",
Tool: "test_tool",
}, patErr, tmpDir, &buf)
if err == nil {
t.Fatal("expected PATError in json PAT mode")
}
if got := strings.TrimSpace(buf.String()); got != "" {
t.Fatalf("expected no human-readable output in json PAT mode, got %q", got)
}
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
}
patOut, ok := err.(*apperrors.PATError)
if !ok {
t.Fatalf("expected *PATError, got %T: %v", err, err)
}
var payload map[string]any
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
t.Fatalf("json.Unmarshal(json PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
}
data, _ := payload["data"].(map[string]any)
if got, ok := data["openBrowser"].(bool); !ok || got {
t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"])
}
if _, ok := data["hostControl"]; ok {
t.Fatalf("unexpected data.hostControl in CLI-owned json PAT mode: %s", patOut.RawJSON)
}
}
func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
tmpDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", tmpDir)
if _, err := pat.SetBrowserPolicy(tmpDir, "", true); err != nil {
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
}
var opened string
origOpenBrowser := openBrowserFunc
openBrowserFunc = func(rawURL string) error {
opened = rawURL
return nil
}
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
mock := &mockRunner{
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
t.Fatal("runner should not be called in json PAT mode")
return executor.Result{}, nil
},
}
runner := &runtimeRunner{
fallback: mock,
globalFlags: &GlobalFlags{Format: "json"},
}
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-json","uri":"https://example.com/pat","clientId":"test-client-id"}}`
var buf bytes.Buffer
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
CanonicalProduct: "test",
Tool: "test_tool",
}, &apperrors.PATError{RawJSON: raw}, tmpDir, &buf)
if err == nil {
t.Fatal("expected PATError in json PAT mode")
}
if got := strings.TrimSpace(buf.String()); got != "" {
t.Fatalf("expected no human-readable output in json PAT mode, got %q", got)
}
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
}
if opened != "https://example.com/pat" {
t.Fatalf("opened url = %q, want https://example.com/pat", opened)
}
}
func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
defer server.Close()
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
}
var opened bool
origOpenBrowser := openBrowserFunc
openBrowserFunc = func(rawURL string) error {
opened = true
return nil
}
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
var retryCalled bool
mock := &mockRunner{
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
retryCalled = true
return executor.Result{Response: map[string]any{"ok": true}}, nil
},
}
runner := &runtimeRunner{
fallback: mock,
globalFlags: &GlobalFlags{Format: "table"},
}
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-approved","uri":"https://example.com/pat","clientId":"test-client-id"}}`
var buf bytes.Buffer
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
CanonicalProduct: "test",
Tool: "test_tool",
}, &apperrors.PATError{RawJSON: raw}, configDir, &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !retryCalled {
t.Fatal("expected retry to still happen in non-json mode")
}
if opened {
t.Fatal("browser should not open when policy disables it")
}
if !strings.Contains(buf.String(), "需要 PAT 授权") {
t.Fatalf("expected human-readable PAT output, got %q", buf.String())
}
}
func TestRetryWithPatAuthRetry_JSONModeReturnsStructuredPATError(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
}
mock := &mockRunner{
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
t.Fatal("runner should not be called in scope json mode")
return executor.Result{}, nil
},
}
runner := &runtimeRunner{
fallback: mock,
globalFlags: &GlobalFlags{Format: "json"},
}
scopeErr := &PatScopeError{
OriginalError: "missing required scope(s): mail:send",
Identity: "user",
ErrorType: "missing_scope",
Message: "missing required scope(s): mail:send",
Hint: "run `dws auth login --scope \"mail:send\"` to authorize the missing scope",
MissingScope: "mail:send",
}
var buf bytes.Buffer
_, err := retryWithPatAuthRetry(context.Background(), runner, executor.Invocation{}, scopeErr, configDir, &buf)
if err == nil {
t.Fatal("expected PATError")
}
if got := strings.TrimSpace(buf.String()); got != "" {
t.Fatalf("expected no human-readable output in json scope mode, got %q", got)
}
patOut, ok := err.(*apperrors.PATError)
if !ok {
t.Fatalf("expected *PATError, got %T: %v", err, err)
}
var payload map[string]any
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
t.Fatalf("json.Unmarshal(scope PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
}
data, _ := payload["data"].(map[string]any)
if got, ok := data["openBrowser"].(bool); !ok || got {
t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"])
}
if _, ok := data["hostControl"]; ok {
t.Fatalf("unexpected data.hostControl in CLI-owned json scope mode: %s", patOut.RawJSON)
}
}
// TestEnrichPATErrorForHostControl_SingleLineOutput locks in the wire
// invariant: the enriched host-controlled PAT payload must be single-line
// (no embedded newlines, no indentation), so stderr-line-scanning hosts
// stay correct. Regression guard against accidental reintroduction of
// json.MarshalIndent.
func TestEnrichPATErrorForHostControl_SingleLineOutput(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
t.Setenv("DINGTALK_AGENT", "sales-copilot")
raw := `{"success":false,"code":"PAT_LOW_RISK_NO_PERMISSION","data":{"flowId":"flow-1","desc":"授权","callbacks":["cb1","cb2"]}}`
out := enrichPATErrorForHostControl(raw)
if strings.Contains(out, "\n") {
t.Fatalf("enrichPATErrorForHostControl output must be single-line, got embedded newline:\n%s", out)
}
if strings.HasPrefix(out, " ") || strings.HasPrefix(out, "\t") {
t.Fatalf("enrichPATErrorForHostControl output must not be indented, got leading whitespace: %q", out)
}
var parsed map[string]any
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("single-line output must round-trip via json.Unmarshal: %v\nraw=%s", err, out)
}
data, _ := parsed["data"].(map[string]any)
hostControl, _ := data["hostControl"].(map[string]any)
if hostControl == nil {
t.Fatalf("expected data.hostControl injection, got: %s", out)
}
if got, _ := hostControl["callbackOwner"].(string); got != "host" {
t.Fatalf("hostControl.callbackOwner = %q, want host", got)
}
if _, ok := data["callbacks"]; ok {
t.Fatalf("expected callbacks to be stripped in host-owned contract, got: %v", data["callbacks"])
}
}
// TestBuildPATScopeHostJSON_SingleLineOutput mirrors the above regression
// for the scope-error branch (PAT_SCOPE_AUTH_REQUIRED emission).
func TestBuildPATScopeHostJSON_SingleLineOutput(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "agt-support")
t.Setenv("DINGTALK_AGENT", "customer-support")
scopeErr := &PatScopeError{
OriginalError: "missing required scope(s): mail:send",
Identity: "user",
ErrorType: "missing_scope",
Message: "missing required scope(s): mail:send",
Hint: "run `dws auth login --scope \"mail:send\"` to authorize",
MissingScope: "mail:send",
}
out := buildPATScopeJSON(scopeErr, true)
if strings.Contains(out, "\n") {
t.Fatalf("buildPATScopeJSON(host) output must be single-line, got embedded newline:\n%s", out)
}
if strings.HasPrefix(out, " ") || strings.HasPrefix(out, "\t") {
t.Fatalf("buildPATScopeJSON(host) output must not be indented, got leading whitespace: %q", out)
}
var parsed map[string]any
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("single-line output must round-trip via json.Unmarshal: %v\nraw=%s", err, out)
}
if code, _ := parsed["code"].(string); code != "PAT_SCOPE_AUTH_REQUIRED" {
t.Errorf("code = %q, want PAT_SCOPE_AUTH_REQUIRED", code)
}
}
func TestRetryWithPatAuthRetry_HostControlledReturnsJSON(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "agt-support")
t.Setenv("DINGTALK_AGENT", "customer-support")
scopeErr := &PatScopeError{
OriginalError: "missing required scope(s): mail:send",
Identity: "user",
ErrorType: "missing_scope",
Message: "missing required scope(s): mail:send",
Hint: "run `dws auth login --scope \"mail:send\"` to authorize the missing scope",
MissingScope: "mail:send",
}
mock := &mockRunner{
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
t.Fatal("runner should not be called in host-controlled scope mode")
return executor.Result{}, nil
},
}
var buf bytes.Buffer
_, err := retryWithPatAuthRetry(context.Background(), mock, executor.Invocation{}, scopeErr, t.TempDir(), &buf)
if err == nil {
t.Fatal("expected PATError")
}
if got := strings.TrimSpace(buf.String()); got != "" {
t.Fatalf("expected no human-readable output, got %q", got)
}
patErr, ok := err.(*apperrors.PATError)
if !ok {
t.Fatalf("expected *PATError, got %T: %v", err, err)
}
var payload map[string]any
if err := json.Unmarshal([]byte(patErr.RawJSON), &payload); err != nil {
t.Fatalf("json.Unmarshal(scope host payload) error = %v\nraw=%s", err, patErr.RawJSON)
}
if got, _ := payload["code"].(string); got != "PAT_SCOPE_AUTH_REQUIRED" {
t.Fatalf("code = %q, want PAT_SCOPE_AUTH_REQUIRED", got)
}
data, _ := payload["data"].(map[string]any)
if got, _ := data["missingScope"].(string); got != "mail:send" {
t.Fatalf("missingScope = %q, want mail:send", got)
}
hostControl, _ := data["hostControl"].(map[string]any)
if got, _ := hostControl["callbackOwner"].(string); got != "host" {
t.Fatalf("hostControl.callbackOwner = %q, want host", got)
}
if _, ok := data["callbacks"]; ok {
t.Fatalf("unexpected callbacks contract in PAT scope host payload: %#v", data["callbacks"])
}
if strings.Contains(patErr.RawJSON, `"pat","callback"`) {
t.Fatalf("scope host payload should not advertise dws pat callback argv: %s", patErr.RawJSON)
}
}
func TestHandlePatAuthCheck_OpensOpaqueURIWithoutRebuild(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
defer server.Close()
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D50dff7654b7444e88ced7489b07cce8d%26userCode%3DQ8RY-X6E9#/personalAuthorization?flowId=50dff7654b7444e88ced7489b07cce8d&userCode=Q8RY-X6E9"
var opened string
origOpenBrowser := openBrowserFunc
openBrowserFunc = func(rawURL string) error {
opened = rawURL
return nil
}
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
var retryCalled bool
mock := &mockRunner{
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
retryCalled = true
return executor.Result{Response: map[string]any{"ok": true}}, nil
},
}
runner := &runtimeRunner{fallback: mock}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-opaque", "test-client-id", rawURI)}
var buf bytes.Buffer
_, err := handlePatAuthCheck(context.Background(), 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 retry to run after approved PAT flow")
}
if opened != rawURI {
t.Fatalf("opened url = %q, want verbatim %q", opened, rawURI)
}
}
+62
View File
@@ -0,0 +1,62 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
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/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// init wires the PAT classifier's hostControl injection hook. This
// guarantees cleanPATJSON emits data.hostControl in host-owned mode
// regardless of whether the PAT error was surfaced via the active retry
// path or the passive classifier path.
//
// Decision rule:
// - Host-owned is triggered iff DINGTALK_DWS_AGENTCODE is non-empty.
// - When triggered, `clawType` in the emitted hostControl block MUST
// be the exact value the CLI actually injects on the wire into the
// `claw-type` HTTP header. The open-source build pins that to
// edition.DefaultOSSClawType ("openClaw") unconditionally — there
// is no per-spawn env override.
// - When DINGTALK_DWS_AGENTCODE is empty the provider returns "" so
// HostControlBlock yields nil and no hostControl block is emitted.
func init() {
apperrors.SetHostControlProvider(hostControlProviderFromEnv)
apperrors.SetPATOpenBrowserProvider(func() bool {
return pat.EffectiveOpenBrowser(defaultConfigDir())
})
}
func hostControlProviderFromEnv() string {
if !authpkg.HostOwnsPATFlow() {
return ""
}
return effectiveClawType()
}
// effectiveClawType returns the literal value that MergeHeaders will
// inject into outbound `claw-type` headers. Going through the edition
// hook (instead of a hard-coded constant) keeps this site correct for
// downstream editions that override MergeHeaders.
func effectiveClawType() string {
if h := edition.Get(); h != nil && h.MergeHeaders != nil {
if v, ok := h.MergeHeaders(map[string]string{})["claw-type"]; ok && v != "" {
return v
}
}
return edition.DefaultOSSClawType
}
+206
View File
@@ -0,0 +1,206 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"encoding/json"
"log/slog"
"os"
"path/filepath"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/spf13/cobra"
)
// resolveStdioOverlay resolves the CLIOverlay for a stdio plugin server
// from its manifest. It supports two forms:
//
// 1. inline JSON object in manifest.MCPServers[key].CLI
// 2. a relative file path (JSON string) pointing to an external overlay
// file anchored at the plugin root (e.g. "overlay.json")
//
// When no CLI metadata is present, a minimal overlay keyed by the server
// name is returned so callers can still build an identity descriptor.
func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) market.CLIOverlay {
serverID := sc.Key
overlay := market.CLIOverlay{
ID: serverID,
Command: serverID,
}
srv, ok := p.Manifest.MCPServers[sc.Key]
if !ok || len(srv.CLI) == 0 {
return overlay
}
cliData := srv.CLI
// A JSON string is interpreted as a relative path to an external
// overlay file (e.g. "overlay.json") anchored at the plugin root.
if len(cliData) > 0 && cliData[0] == '"' {
var cliPath string
if err := json.Unmarshal(cliData, &cliPath); err == nil && cliPath != "" {
absPath := filepath.Join(p.Root, cliPath)
if fileData, readErr := os.ReadFile(absPath); readErr == nil {
cliData = fileData
} else {
slog.Warn("plugin: failed to read CLI overlay file",
"plugin", p.Manifest.Name, "path", absPath, "error", readErr)
}
}
}
if err := json.Unmarshal(cliData, &overlay); err != nil {
slog.Warn("plugin: failed to parse CLI overlay for stdio server",
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
}
if overlay.ID == "" {
overlay.ID = serverID
}
if overlay.Command == "" {
overlay.Command = serverID
}
return overlay
}
// toolsToDetails converts discovered ToolDescriptors to the DetailTool map
// shape expected by compat.BuildDynamicCommands (keyed by overlay ID).
// Returns nil if tools is empty.
func toolsToDetails(tools []transport.ToolDescriptor, overlayID string) map[string][]market.DetailTool {
if len(tools) == 0 {
return nil
}
detailTools := make([]market.DetailTool, 0, len(tools))
for _, tool := range tools {
schemaJSON := ""
if tool.InputSchema != nil {
if data, marshalErr := json.Marshal(tool.InputSchema); marshalErr == nil {
schemaJSON = string(data)
}
}
detailTools = append(detailTools, market.DetailTool{
ToolName: tool.Name,
ToolTitle: tool.Title,
ToolDesc: tool.Description,
IsSensitive: tool.Sensitive,
ToolRequest: schemaJSON,
})
}
return map[string][]market.DetailTool{overlayID: detailTools}
}
// registerStdioServerFromOverlay builds cobra commands for a stdio plugin
// server using only its manifest + overlay.json — no subprocess required.
//
// Returns (cmds, descriptor, true) when the overlay carries toolOverrides,
// otherwise (nil, zero, false) so the caller can fall back to discovery-first
// registration (legacy path).
//
// When a warm tools cache exists for this server, its DetailTools are passed
// to BuildDynamicCommands so flag types are enriched from the last successful
// discovery. Fresh installs (or evicted caches) get overlay-declared flags
// only; the next startup after a successful refresh picks up the full schema.
func registerStdioServerFromOverlay(
p *plugin.Plugin,
sc plugin.StdioServerClient,
runner executor.Runner,
store *cache.Store,
) ([]*cobra.Command, market.ServerDescriptor, bool) {
overlay := resolveStdioOverlay(p, sc)
if len(overlay.ToolOverrides) == 0 {
return nil, market.ServerDescriptor{}, false
}
descriptor := market.ServerDescriptor{
Key: sc.Key,
DisplayName: p.Manifest.Name + "/" + sc.Key,
Description: p.Manifest.Description,
Endpoint: StdioEndpoint(p.Manifest.Name, sc.Key),
Source: "plugin",
CLI: overlay,
HasCLIMeta: true,
}
AppendDynamicServer(descriptor)
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
// Warm-cache enrichment: if a prior successful discovery wrote a
// non-empty tool list, use its schema to enrich flag types.
var detailsByID map[string][]market.DetailTool
if store != nil {
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
if snapshot, _, err := store.LoadTools(config.DefaultPartition, cacheKey); err == nil && len(snapshot.Tools) > 0 {
detailsByID = toolsToDetails(snapshot.Tools, overlay.ID)
}
}
cmds := compat.BuildDynamicCommands(
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
slog.Debug("plugin: stdio server registered from overlay",
"plugin", p.Manifest.Name, "server", sc.Key,
"toolOverrides", len(overlay.ToolOverrides),
"commands", len(cmds),
"enriched", detailsByID != nil)
return cmds, descriptor, true
}
// refreshStdioToolsCache performs Initialize + ListTools on a stdio plugin
// subprocess and persists the result so the next startup can enrich
// overlay-registered commands with typed flags. It never constructs cobra
// commands; command registration has already happened synchronously from
// the overlay before this function runs.
//
// On failure (subprocess not ready, RPC timeout, empty tool list) it skips
// SaveTools entirely so a transient error cannot poison the warm cache
// with a null-tools snapshot.
func refreshStdioToolsCache(
p *plugin.Plugin,
sc plugin.StdioServerClient,
store *cache.Store,
timeouts pluginColdTimeouts,
) {
if store == nil {
return
}
tools := discoverStdioTools(p, sc, timeouts)
if len(tools) == 0 {
slog.Debug("plugin: stdio cache refresh skipped (no tools)",
"plugin", p.Manifest.Name, "server", sc.Key)
return
}
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
if err := store.SaveTools(config.DefaultPartition, cacheKey, cache.ToolsSnapshot{
ServerKey: cacheKey,
Tools: tools,
}); err != nil {
slog.Warn("plugin: failed to persist stdio tools cache",
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
return
}
slog.Debug("plugin: stdio tools cache refreshed",
"plugin", p.Manifest.Name, "server", sc.Key, "tools", len(tools))
}
// hasOverlayToolOverrides reports whether a stdio plugin server carries
// enough CLI metadata to be registered via the overlay-first path. Used by
// loadPlugins to split entries into overlay-first vs. legacy discovery-first
// buckets without doing the overlay parse twice.
func hasOverlayToolOverrides(p *plugin.Plugin, sc plugin.StdioServerClient) bool {
return len(resolveStdioOverlay(p, sc).ToolOverrides) > 0
}
+398
View File
@@ -0,0 +1,398 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"encoding/json"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/spf13/cobra"
)
// withCleanStdioRegistry snapshots and restores the package-level stdio
// client registry so tests that call RegisterStdioClient don't leak state
// across cases.
func withCleanStdioRegistry(t *testing.T) {
t.Helper()
stdioMu.Lock()
prev := stdioClients
stdioClients = make(map[string]*transport.StdioClient)
stdioMu.Unlock()
t.Cleanup(func() {
stdioMu.Lock()
stdioClients = prev
stdioMu.Unlock()
})
}
// newOverlayFixture constructs a plugin + stdio entry carrying an inline
// CLIOverlay with the given tool-override map. The stdio client is created
// but never started, since the overlay-first path does not require the
// subprocess to be running for command registration.
func newOverlayFixture(t *testing.T, pluginName, serverKey string, overlay market.CLIOverlay) (*plugin.Plugin, plugin.StdioServerClient) {
t.Helper()
raw, err := json.Marshal(overlay)
if err != nil {
t.Fatalf("marshal overlay: %v", err)
}
p := &plugin.Plugin{
Manifest: plugin.Manifest{
Name: pluginName,
Version: "1.0.0",
Description: pluginName + " plugin",
MCPServers: map[string]*plugin.MCPServer{
serverKey: {
Type: "stdio",
Command: "/usr/bin/true", // never executed by overlay-first path
CLI: raw,
},
},
},
Root: t.TempDir(),
}
sc := plugin.StdioServerClient{
Key: serverKey,
Client: transport.NewStdioClient("/usr/bin/true", nil, nil),
}
return p, sc
}
// TestRegisterStdioServerFromOverlay_NoDiscoveryStillBuildsCommands verifies
// the core promise of the overlay-first path: when overlay.json ships
// ToolOverrides, commands appear immediately — no subprocess probe.
func TestRegisterStdioServerFromOverlay_NoDiscoveryStillBuildsCommands(t *testing.T) {
withCleanDynamicRegistry(t)
withCleanStdioRegistry(t)
overlay := market.CLIOverlay{
ID: "conference-local",
Command: "conference-local",
Groups: map[string]market.CLIGroupDef{
"meeting": {Description: "会议控制"},
"member": {Description: "成员管理"},
},
ToolOverrides: map[string]market.CLIToolOverride{
"create_meeting": {CLIName: "create", Group: "meeting", Description: "Create a meeting"},
"end_meeting": {CLIName: "end", Group: "meeting", Description: "End a meeting"},
"mute_member": {CLIName: "mute", Group: "member", Description: "Mute a member"},
},
}
p, sc := newOverlayFixture(t, "conference-local", "conference-local", overlay)
store := cache.NewStore(t.TempDir())
cmds, desc, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
if !ok {
t.Fatal("registerStdioServerFromOverlay returned ok=false, want true")
}
if len(cmds) == 0 {
t.Fatal("registerStdioServerFromOverlay returned 0 commands, want >=1")
}
var root *struct{ name, path string }
_ = root
found := false
for _, c := range cmds {
if c.Name() == "conference-local" {
found = true
// Groups must be attached as sub-commands.
groups := map[string]bool{}
for _, sub := range c.Commands() {
groups[sub.Name()] = true
}
if !groups["meeting"] {
t.Errorf("missing 'meeting' group sub-command, children = %v", groups)
}
if !groups["member"] {
t.Errorf("missing 'member' group sub-command, children = %v", groups)
}
}
}
if !found {
names := []string{}
for _, c := range cmds {
names = append(names, c.Name())
}
t.Fatalf("missing top-level 'conference-local' command, got %v", names)
}
// AppendDynamicServer registration: product ID should land in
// DirectRuntimeProductIDs so hideNonDirectRuntimeCommands keeps it
// visible even under a restrictive VisibleProducts hook.
if !DirectRuntimeProductIDs()["conference-local"] {
t.Error("DirectRuntimeProductIDs missing 'conference-local'")
}
// RegisterStdioClient side-effect: the runtime must be able to look up
// the StdioClient when the endpoint is invoked later.
if _, ok := LookupStdioClient("conference-local/conference-local"); !ok {
t.Error("LookupStdioClient missing conference-local/conference-local")
}
if desc.Endpoint != StdioEndpoint("conference-local", "conference-local") {
t.Errorf("descriptor.Endpoint = %q, want %q", desc.Endpoint, StdioEndpoint("conference-local", "conference-local"))
}
}
// TestRegisterStdioServerFromOverlay_WarmCacheEnrichesFlags pre-populates the
// tools cache with a schema-bearing DetailTool and asserts the resulting
// leaf command picks up the typed flag derived from InputSchema.
func TestRegisterStdioServerFromOverlay_WarmCacheEnrichesFlags(t *testing.T) {
withCleanDynamicRegistry(t)
withCleanStdioRegistry(t)
overlay := market.CLIOverlay{
ID: "cache-plugin",
Command: "cache-plugin",
ToolOverrides: map[string]market.CLIToolOverride{
"echo": {CLIName: "echo", Description: "Echo input"},
},
}
p, sc := newOverlayFixture(t, "cache-plugin", "cache-plugin", overlay)
store := cache.NewStore(t.TempDir())
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
if err := store.SaveTools(config.DefaultPartition, cacheKey, cache.ToolsSnapshot{
SavedAt: time.Now().UTC(),
ServerKey: cacheKey,
Tools: []transport.ToolDescriptor{
{
Name: "echo",
Description: "Echo the input",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"message": map[string]any{"type": "string"},
},
"required": []any{"message"},
},
},
},
}); err != nil {
t.Fatalf("SaveTools: %v", err)
}
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
if !ok || len(cmds) == 0 {
t.Fatalf("overlay registration failed: ok=%v cmds=%d", ok, len(cmds))
}
var echoLeaf *leafMatch
for _, top := range cmds {
if top.Name() != "cache-plugin" {
continue
}
for _, sub := range top.Commands() {
if sub.Name() == "echo" {
echoLeaf = &leafMatch{name: sub.Name(), hasFlag: sub.Flags().Lookup("message") != nil}
}
}
}
if echoLeaf == nil {
t.Fatal("missing 'echo' leaf command under 'cache-plugin'")
}
if !echoLeaf.hasFlag {
t.Error("warm-cache enrichment did not wire --message flag from InputSchema")
}
}
type leafMatch struct {
name string
hasFlag bool
}
// TestRegisterStdioServerFromOverlay_OverlayWithoutOverridesReturnsFalse
// asserts the fallback contract: when overlay.json is missing toolOverrides,
// the overlay-first path declines so the caller can route the entry through
// the legacy discovery-first registerStdioServer.
func TestRegisterStdioServerFromOverlay_OverlayWithoutOverridesReturnsFalse(t *testing.T) {
withCleanDynamicRegistry(t)
withCleanStdioRegistry(t)
// Overlay with no ToolOverrides (simulates a plugin that relies entirely
// on runtime discovery for its tool list).
overlay := market.CLIOverlay{
ID: "legacy-plugin",
Command: "legacy-plugin",
}
p, sc := newOverlayFixture(t, "legacy-plugin", "legacy-plugin", overlay)
store := cache.NewStore(t.TempDir())
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
if ok {
t.Errorf("registerStdioServerFromOverlay ok=true for empty toolOverrides; want false")
}
if cmds != nil {
t.Errorf("cmds = %v, want nil", cmds)
}
if DirectRuntimeProductIDs()["legacy-plugin"] {
t.Error("legacy-plugin must NOT be appended to dynamic registry in fallback case")
}
if _, found := LookupStdioClient("legacy-plugin/legacy-plugin"); found {
t.Error("stdio client must NOT be registered in fallback case")
}
}
// TestRefreshStdioToolsCache_FailurePreservesCache guards against the
// "negative cache poisoning" bug: if discovery fails (subprocess not ready,
// timeout, empty tool list), the existing warm cache must remain intact so
// the next startup still enriches flags from the last good snapshot.
func TestRefreshStdioToolsCache_FailurePreservesCache(t *testing.T) {
withCleanDynamicRegistry(t)
withCleanStdioRegistry(t)
p, sc := newOverlayFixture(t, "refresh-plugin", "refresh-plugin", market.CLIOverlay{
ID: "refresh-plugin",
Command: "refresh-plugin",
})
store := cache.NewStore(t.TempDir())
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
goodSnapshot := cache.ToolsSnapshot{
SavedAt: time.Now().UTC(),
ServerKey: cacheKey,
Tools: []transport.ToolDescriptor{
{
Name: "ping",
Description: "Health check",
InputSchema: map[string]any{"type": "object"},
},
},
}
if err := store.SaveTools(config.DefaultPartition, cacheKey, goodSnapshot); err != nil {
t.Fatalf("seed SaveTools: %v", err)
}
// /usr/bin/true exits immediately, so Initialize + ListTools will fail
// (no MCP handshake). discoverStdioTools returns nil → refresh must be
// a no-op and must NOT overwrite the good cache with a null snapshot.
refreshStdioToolsCache(p, sc, store, pluginColdTimeouts{stdio: 200 * time.Millisecond})
got, _, err := store.LoadTools(config.DefaultPartition, cacheKey)
if err != nil {
t.Fatalf("LoadTools after failed refresh: %v", err)
}
if len(got.Tools) != 1 || got.Tools[0].Name != "ping" {
t.Errorf("warm cache was overwritten by failed refresh: %+v", got.Tools)
}
}
// TestLoadPlugins_OverlayFirstVisibleBeforeDiscovery is an integration-style
// test for the loadPlugins split decision: stdio plugins whose overlay ships
// ToolOverrides must have their commands visible on the root immediately,
// WITHOUT waiting on any discovery handshake. It drives the same sequence
// loadPlugins uses (registerStdioServerFromOverlay → root.AddCommand →
// hideNonDirectRuntimeCommands) and asserts the plugin command survives the
// visibility filter even when no discovery has run.
func TestLoadPlugins_OverlayFirstVisibleBeforeDiscovery(t *testing.T) {
withCleanDynamicRegistry(t)
withCleanStdioRegistry(t)
// Simulate a wukong-like edition that declares a static VisibleProducts
// whitelist NOT containing our plugin. This is the exact scenario where
// the original bug surfaced.
overrideVisibleProducts(t, []string{"calendar", "doc"})
overlay := market.CLIOverlay{
ID: "conference-local",
Command: "conference-local",
ToolOverrides: map[string]market.CLIToolOverride{
"create_meeting": {CLIName: "create", Description: "Create a meeting"},
},
}
p, sc := newOverlayFixture(t, "conference-local", "conference-local", overlay)
// No discovery runs — no cache seeded. This mirrors a cold-start where
// the subprocess is unavailable (or just slow) yet the user expects
// `dws --help` to still list the plugin.
store := cache.NewStore(t.TempDir())
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
if !ok {
t.Fatal("registerStdioServerFromOverlay returned ok=false")
}
root := &cobra.Command{Use: "dws"}
// Also add a sibling command that is NOT a registered product so we can
// prove the visibility filter still hides non-product commands.
bogus := &cobra.Command{Use: "bogus-not-a-product"}
root.AddCommand(bogus)
for _, c := range cmds {
root.AddCommand(c)
}
hideNonDirectRuntimeCommands(root)
var pluginCmd *cobra.Command
for _, c := range root.Commands() {
if c.Name() == "conference-local" {
pluginCmd = c
}
}
if pluginCmd == nil {
t.Fatal("conference-local missing from root after overlay-first registration")
}
if pluginCmd.Hidden {
t.Error("conference-local must stay visible (Hidden=false) after hideNonDirectRuntimeCommands")
}
if !bogus.Hidden {
t.Error("bogus-not-a-product must be hidden by the visibility filter")
}
services := visibleMCPRootCommands(root)
if !containsCommand(services, "conference-local") {
t.Errorf("visibleMCPRootCommands missing conference-local: %v", commandNames(services))
}
}
// TestHasOverlayToolOverrides exercises the split-decision helper used by
// loadPlugins to route stdio entries to overlay-first vs. legacy buckets.
func TestHasOverlayToolOverrides(t *testing.T) {
cases := []struct {
name string
overlay market.CLIOverlay
want bool
}{
{
name: "empty overlay",
overlay: market.CLIOverlay{ID: "x", Command: "x"},
want: false,
},
{
name: "overlay with overrides",
overlay: market.CLIOverlay{
ID: "x",
Command: "x",
ToolOverrides: map[string]market.CLIToolOverride{
"foo": {CLIName: "foo"},
},
},
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p, sc := newOverlayFixture(t, "x", "x", tc.overlay)
got := hasOverlayToolOverrides(p, sc)
if got != tc.want {
t.Errorf("hasOverlayToolOverrides = %v, want %v", got, tc.want)
}
})
}
}
+55 -78
View File
@@ -290,6 +290,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
utilityCommands := []*cobra.Command{
newAuthCommand(),
newAPICommand(flags),
newSkillCommand(),
newCacheCommand(),
newConfigCommand(),
@@ -655,18 +656,10 @@ func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executo
// Public utility commands (auth, cache, completion, version) are always kept
// visible; explicitly hidden commands stay hidden.
func hideNonDirectRuntimeCommands(root *cobra.Command) {
var allowedProducts map[string]bool
if fn := edition.Get().VisibleProducts; fn != nil {
products := fn()
allowedProducts = make(map[string]bool, len(products))
for _, p := range products {
allowedProducts[p] = true
}
} else {
allowedProducts = DirectRuntimeProductIDs()
}
allowedProducts := resolveVisibleProducts()
staticCommands := map[string]bool{
"auth": true,
"api": true,
"cache": true,
"config": true,
"doctor": true,
@@ -698,7 +691,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
// not override. This protects core CLI functionality from being hijacked
// by a malicious or misconfigured plugin.
var reservedCommands = map[string]bool{
"auth": true, "login": true, "logout": true,
"auth": true, "api": true, "login": true, "logout": true,
"plugin": true, "skill": true, "cache": true,
"config": true, "doctor": true, "completion": true,
"recovery": true, "upgrade": true, "version": true,
@@ -1162,11 +1155,31 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
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.
// Phase A: stdio overlay-first registration (synchronous, no I/O).
// Plugins whose overlay.json declares ToolOverrides register their full
// command tree up-front from manifest metadata alone — no subprocess
// handshake required. This fixes the "discovery fails → no commands
// ever appear" lock-out and keeps `dws --help` reliable even when the
// underlying MCP server is temporarily unavailable.
var legacyStdioEntries []stdioEntry
for _, e := range stdioEntries {
cmds, _, ok := registerStdioServerFromOverlay(e.plugin, e.sc, runner, sharedStore)
if !ok {
legacyStdioEntries = append(legacyStdioEntries, e)
continue
}
pluginCmds = append(pluginCmds, cmds...)
}
// Phase B: fan out discovery in parallel.
// - HTTP plugins: same behaviour as before (discovery-first).
// - stdio overlay-first plugins: async cache refresh only; their
// commands are already registered. Failures are non-fatal and do
// NOT poison the warm-cache with a null-tools snapshot.
// - stdio legacy plugins (overlay without toolOverrides): preserve
// the old discovery-first path for backwards compatibility.
httpResults := make([][]*cobra.Command, len(httpServers))
stdioResults := make([][]*cobra.Command, len(stdioEntries))
legacyStdioResults := make([][]*cobra.Command, len(legacyStdioEntries))
var wg sync.WaitGroup
for i, ps := range httpServers {
wg.Add(1)
@@ -1175,18 +1188,30 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
httpResults[idx] = registerHTTPServer(ps.plugin, ps.srv, tc, runner, sharedStore, coldTimeouts)
}(i, ps)
}
for i, e := range stdioEntries {
// overlay-first stdio: async refresh (no command building here).
for _, e := range stdioEntries {
if !hasOverlayToolOverrides(e.plugin, e.sc) {
continue
}
wg.Add(1)
go func(e stdioEntry) {
defer wg.Done()
refreshStdioToolsCache(e.plugin, e.sc, sharedStore, coldTimeouts)
}(e)
}
// legacy stdio: discovery-first (commands depend on tool list).
for i, e := range legacyStdioEntries {
wg.Add(1)
go func(idx int, e stdioEntry) {
defer wg.Done()
stdioResults[idx] = registerStdioServer(e.plugin, e.sc, runner, sharedStore, coldTimeouts)
legacyStdioResults[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 {
for _, cmds := range legacyStdioResults {
pluginCmds = append(pluginCmds, cmds...)
}
@@ -1472,6 +1497,11 @@ func discoverStdioTools(p *plugin.Plugin, sc plugin.StdioServerClient, timeouts
// buildStdioCommands constructs Cobra commands from a tool list and
// registers the runtime dispatch state (StdioClient + dynamic server).
// Returns nil for an empty tool list.
//
// This is the legacy discovery-first path, used only for stdio plugins whose
// overlay.json does NOT carry toolOverrides. Plugins that ship toolOverrides
// register commands up-front via registerStdioServerFromOverlay, bypassing
// this function entirely (see plugin_stdio_overlay.go).
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",
@@ -1479,44 +1509,14 @@ func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []t
return nil
}
// Build CLIOverlay: use manifest CLI metadata if present, else auto-generate.
serverID := sc.Key
overlay := market.CLIOverlay{
ID: serverID,
Command: serverID,
}
if srv, ok := p.Manifest.MCPServers[sc.Key]; ok && len(srv.CLI) > 0 {
cliData := srv.CLI
// If cli is a JSON string, treat it as a relative file path to an overlay file.
if len(cliData) > 0 && cliData[0] == '"' {
var cliPath string
if err := json.Unmarshal(cliData, &cliPath); err == nil && cliPath != "" {
absPath := filepath.Join(p.Root, cliPath)
if fileData, readErr := os.ReadFile(absPath); readErr == nil {
cliData = fileData
} else {
slog.Warn("plugin: failed to read CLI overlay file",
"plugin", p.Manifest.Name, "path", absPath, "error", readErr)
}
}
}
if err := json.Unmarshal(cliData, &overlay); err != nil {
slog.Warn("plugin: failed to parse CLI overlay for stdio server",
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
}
if overlay.ID == "" {
overlay.ID = serverID
}
if overlay.Command == "" {
overlay.Command = serverID
}
}
overlay := resolveStdioOverlay(p, sc)
// Auto-generate ToolOverrides from discovered tools when not provided.
// Auto-generate ToolOverrides from discovered tools when not provided
// by the manifest/overlay (legacy discovery-first path).
if len(overlay.ToolOverrides) == 0 {
overlay.ToolOverrides = make(map[string]market.CLIToolOverride)
if len(overlay.Prefixes) == 0 {
overlay.Prefixes = []string{serverID}
overlay.Prefixes = []string{overlay.ID}
}
for _, tool := range tools {
overlay.ToolOverrides[tool.Name] = market.CLIToolOverride{
@@ -1525,43 +1525,20 @@ func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []t
}
}
// Construct virtual endpoint and server descriptor.
endpoint := StdioEndpoint(p.Manifest.Name, sc.Key)
descriptor := market.ServerDescriptor{
Key: sc.Key,
DisplayName: p.Manifest.Name + "/" + sc.Key,
Description: p.Manifest.Description,
Endpoint: endpoint,
Endpoint: StdioEndpoint(p.Manifest.Name, sc.Key),
Source: "plugin",
CLI: overlay,
HasCLIMeta: true,
}
AppendDynamicServer(descriptor)
// Register with pluginName/serverKey format for cleanup by plugin name
RegisterStdioClient(p.Manifest.Name+"/"+serverID, sc.Client)
// Convert tool descriptors to DetailTool entries for flag generation.
detailsByID := make(map[string][]market.DetailTool)
var detailTools []market.DetailTool
for _, tool := range tools {
schemaJSON := ""
if tool.InputSchema != nil {
if data, marshalErr := json.Marshal(tool.InputSchema); marshalErr == nil {
schemaJSON = string(data)
}
}
detailTools = append(detailTools, market.DetailTool{
ToolName: tool.Name,
ToolTitle: tool.Title,
ToolDesc: tool.Description,
IsSensitive: tool.Sensitive,
ToolRequest: schemaJSON,
})
}
detailsByID[serverID] = detailTools
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
detailsByID := toolsToDetails(tools, overlay.ID)
cmds := compat.BuildDynamicCommands(
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
+20 -17
View File
@@ -84,21 +84,30 @@ func renderRootHelp(root *cobra.Command) {
_, _ = fmt.Fprintln(w, `Use "dws <service> --help" for more information about a discovered MCP service or "dws <command> --help" for utility commands.`)
}
// resolveVisibleProducts returns the set of top-level product IDs that should
// be treated as visible. It unions the edition's VisibleProducts hook (when
// set) with DirectRuntimeProductIDs(), so dynamically-registered products —
// including plugins loaded via AppendDynamicServer — are never silently hidden
// by a static VisibleProducts list.
func resolveVisibleProducts() map[string]bool {
allowed := map[string]bool{}
if fn := edition.Get().VisibleProducts; fn != nil {
for _, p := range fn() {
allowed[p] = true
}
}
for id := range DirectRuntimeProductIDs() {
allowed[id] = true
}
return allowed
}
func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command {
if root == nil {
return nil
}
var allowed map[string]bool
if fn := edition.Get().VisibleProducts; fn != nil {
products := fn()
allowed = make(map[string]bool, len(products))
for _, p := range products {
allowed[p] = true
}
} else {
allowed = DirectRuntimeProductIDs()
}
allowed := resolveVisibleProducts()
if len(allowed) == 0 {
return nil
}
@@ -121,13 +130,7 @@ func visibleUtilityRootCommands(root *cobra.Command) []*cobra.Command {
return nil
}
productCommands := DirectRuntimeProductIDs()
if fn := edition.Get().VisibleProducts; fn != nil {
productCommands = make(map[string]bool, len(fn()))
for _, product := range fn() {
productCommands[product] = true
}
}
productCommands := resolveVisibleProducts()
commands := make([]*cobra.Command, 0)
for _, cmd := range root.Commands() {
+51 -6
View File
@@ -59,7 +59,7 @@ func init() {
configmeta.Register(configmeta.ConfigItem{
Name: "DINGTALK_AGENT",
Category: configmeta.CategoryExternal,
Description: "MCP 请求 x-dingtalk-agent 头",
Description: "业务 Agent 名称;仅用于 x-dingtalk-agent 请求头,与 claw-type/host-owned PAT 判定无关",
})
configmeta.Register(configmeta.ConfigItem{
Name: "DINGTALK_TRACE_ID",
@@ -93,6 +93,27 @@ const (
envDWSChannel = "DWS_CHANNEL"
)
// hostOwnedPATDecisionOnce ensures the host-owned PAT decision is logged at
// most once per CLI process. The log line is emitted at Debug level so
// `--debug` (or `--verbose`) surfaces it on stderr; the file logger at
// ~/.dws/logs/dws.log captures it unconditionally at DEBUG. It records
// ONLY the derived booleans — never the env value, token, client-id or
// flow-id — so logs remain safe to attach to issues.
var hostOwnedPATDecisionOnce sync.Once
// logHostOwnedPATDecisionOnce emits the single-shot debug trace. It is
// called lazily from the runtime Run path (which executes AFTER
// PersistentPreRunE has applied --debug / --verbose via configureLogLevel)
// so the line actually surfaces when the user asks for it.
func logHostOwnedPATDecisionOnce() {
hostOwnedPATDecisionOnce.Do(func() {
slog.Debug("runtime.host_owned_pat",
"hostOwned", authpkg.HostOwnsPATFlow(),
"agentCodeEnvPresent", os.Getenv(authpkg.AgentCodeEnv) != "",
)
})
}
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
@@ -132,6 +153,12 @@ type runtimeRunner struct {
}
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
// Emit the one-shot host-owned PAT decision log. Placed here (not in
// the constructor) so it fires AFTER PersistentPreRunE has configured
// slog level per --debug / --verbose. The Once guard makes repeat
// invocations within the same process free.
logHostOwnedPATDecisionOnce()
if r.loader == nil || r.transport == nil {
return r.fallback.Run(ctx, invocation)
}
@@ -171,6 +198,17 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
return r.handleCatalogMiss(ctx, invocation, "product missing from discovery catalog and no supplement/env override")
}
if _, ok := product.FindTool(invocation.Tool); !ok {
// Catalog knows the product but not the tool — this happens when the
// catalog entry came from SupplementServers (endpoint-only, no tool
// list). Trust directRuntimeEndpoint to re-resolve a working endpoint
// for the tool. If that also misses, fall through to handleCatalogMiss
// so stderr still carries the explicit not-resolved signal.
if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok {
if r.globalFlags != nil && r.globalFlags.DryRun {
invocation.DryRun = true
}
return r.executeInvocation(ctx, endpoint, invocation)
}
return r.handleCatalogMiss(ctx, invocation, fmt.Sprintf("tool %q not declared by product %q in discovery catalog", invocation.Tool, invocation.CanonicalProduct))
}
if r.globalFlags != nil && r.globalFlags.DryRun {
@@ -610,11 +648,17 @@ func resolveIdentityHeaders() map[string]string {
}
// Inject environment variable based headers for MCP gateway tracking.
// DINGTALK_AGENT, if set by the caller, is forwarded verbatim as the
// x-dingtalk-agent header. It does NOT influence claw-type (which the
// open-source edition pins to edition.DefaultOSSClawType via the
// MergeHeaders hook below) and it does NOT influence the host-owned
// PAT decision (driven solely by DINGTALK_DWS_AGENTCODE).
envHeaders := map[string]string{
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
"x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID),
"x-dingtalk-session-id": os.Getenv(envDingtalkSessionID),
"x-dingtalk-message-id": os.Getenv(envDingtalkMessageID),
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
"x-dingtalk-dws-agent-code": strings.TrimSpace(os.Getenv(authpkg.AgentCodeEnv)),
"x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID),
"x-dingtalk-session-id": os.Getenv(envDingtalkSessionID),
"x-dingtalk-message-id": os.Getenv(envDingtalkMessageID),
}
for k, v := range envHeaders {
if v != "" {
@@ -622,7 +666,8 @@ func resolveIdentityHeaders() map[string]string {
}
}
// Inject third-party channel headers
// Inject third-party channel headers. DWS_CHANNEL is forwarded as the
// upstream channelCode.
if v := os.Getenv(envDWSChannel); v != "" {
headers["x-dws-channel"] = v
}
@@ -0,0 +1,192 @@
// 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"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
// supplementOnlyCatalogLoader mimics the post-fix EnvironmentLoader: the
// catalog has the product entry (materialised from SupplementServers) but
// no tool list — the overlay owns the tool tree locally.
type supplementOnlyCatalogLoader struct{}
func (supplementOnlyCatalogLoader) Load(_ context.Context) (ir.Catalog, error) {
return ir.Catalog{
Products: []ir.CanonicalProduct{
{
ID: "conference",
ServerKey: "conference",
Endpoint: "stdio://conference-catalog",
Tools: nil,
},
},
}, nil
}
func resetDynamicServers(t *testing.T) {
t.Helper()
orig := snapshotDynamicServers()
t.Cleanup(func() { restoreDynamicServers(orig) })
}
type dynamicServerSnapshot struct {
endpoints map[string]string
products map[string]bool
aliases map[string]string
toolEndpoints map[string]string
}
func snapshotDynamicServers() dynamicServerSnapshot {
dynamicMu.RLock()
defer dynamicMu.RUnlock()
return dynamicServerSnapshot{
endpoints: cloneStringMap(dynamicEndpoints),
products: cloneBoolMap(dynamicProducts),
aliases: cloneStringMap(dynamicAliases),
toolEndpoints: cloneStringMap(dynamicToolEndpoints),
}
}
func restoreDynamicServers(s dynamicServerSnapshot) {
dynamicMu.Lock()
defer dynamicMu.Unlock()
dynamicEndpoints = s.endpoints
dynamicProducts = s.products
dynamicAliases = s.aliases
dynamicToolEndpoints = s.toolEndpoints
}
func cloneStringMap(in map[string]string) map[string]string {
if in == nil {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func cloneBoolMap(in map[string]bool) map[string]bool {
if in == nil {
return nil
}
out := make(map[string]bool, len(in))
for k, v := range in {
out[k] = v
}
return out
}
// TestRuntimeRunner_ToolMiss_FallsBackToDirectRuntime pins the runner's
// bridge between the catalog path (where a product entry can come from
// SupplementServers with no tool list) and the direct-runtime path (which
// carries the authoritative per-tool endpoint map). When the catalog knows
// the product but not the tool, the runner should not fail-fast with
// endpoint_not_resolved — it should consult dynamicEndpoints one more time
// and proceed if an endpoint is registered.
//
// This is the narrow recovery path that keeps hardcoded overlay commands
// working under a gray-released envelope: the supplement-materialised
// catalog entry has endpoint+no tools, and SetDynamicServers holds the
// operational endpoint indexed by product / command.
func TestRuntimeRunner_ToolMiss_FallsBackToDirectRuntime(t *testing.T) {
resetDynamicServers(t)
SetDynamicServers([]market.ServerDescriptor{
{
Key: "conference",
DisplayName: "会议",
Endpoint: "stdio://conference-fake",
CLI: market.CLIOverlay{
ID: "conference",
Command: "conference",
},
Source: "edition_supplement",
},
})
runner := &runtimeRunner{
loader: supplementOnlyCatalogLoader{},
transport: transport.NewClient(nil),
fallback: executor.EchoRunner{},
}
// Kind = api_invocation forces the code to skip the Run() opening
// direct-runtime attempt and go through the catalog path instead, so
// the tool-miss recovery branch we're testing actually runs.
inv := executor.Invocation{
Kind: "api_invocation",
CanonicalProduct: "conference",
Tool: "create_meeting_reservation",
CanonicalPath: "conference.create_meeting_reservation",
DryRun: true,
Params: map[string]any{},
}
result, err := runner.Run(context.Background(), inv)
if err != nil {
t.Fatalf("runner.Run returned error, want tool-miss fallback success: %v", err)
}
if result.Response == nil {
t.Fatalf("expected non-nil Response on dry-run")
}
if got, _ := result.Response["dry_run"].(bool); !got {
t.Fatalf("expected dry_run=true in Response, got %v", result.Response)
}
if got, _ := result.Response["transport"].(string); got != "stdio" {
t.Fatalf("expected transport=stdio in Response (proof we hit stdio://conference-fake), got %v", result.Response)
}
}
// TestRuntimeRunner_ToolMiss_NoDynamicEntry_StillFailsClosed is the inverse
// guard: when both the catalog tool list and dynamicEndpoints have no
// record for the requested tool, the runner must still surface
// endpoint_not_resolved instead of silently producing empty output.
func TestRuntimeRunner_ToolMiss_NoDynamicEntry_StillFailsClosed(t *testing.T) {
resetDynamicServers(t)
SetDynamicServers([]market.ServerDescriptor{}) // intentionally empty
runner := &runtimeRunner{
loader: supplementOnlyCatalogLoader{},
transport: transport.NewClient(nil),
fallback: executor.EchoRunner{},
}
inv := executor.Invocation{
Kind: "api_invocation",
CanonicalProduct: "conference",
Tool: "nonexistent_tool",
CanonicalPath: "conference.nonexistent_tool",
Params: map[string]any{},
}
_, err := runner.Run(context.Background(), inv)
if err == nil {
t.Fatalf("expected endpoint_not_resolved error, got nil")
}
if !strings.Contains(err.Error(), "endpoint not resolved") {
t.Fatalf("expected endpoint_not_resolved error, got %v", err)
}
if !strings.Contains(err.Error(), "nonexistent_tool") {
t.Fatalf("error should name the missing tool; got %v", err)
}
}
+11
View File
@@ -25,6 +25,7 @@ import (
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -313,6 +314,16 @@ func TestRuntimeRunnerInjectsAuthTokenFromFlag(t *testing.T) {
}
}
func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv(authpkg.AgentCodeEnv, " cursor ")
headers := resolveIdentityHeaders()
if got := headers["x-dingtalk-dws-agent-code"]; got != "cursor" {
t.Fatalf("x-dingtalk-dws-agent-code = %q, want cursor", got)
}
}
// TestRuntimeRunnerRejectsUnauthenticatedRequest verifies that requests without
// a valid token are rejected with a clear error before making any network call.
func TestRuntimeRunnerRejectsUnauthenticatedRequest(t *testing.T) {
+125
View File
@@ -0,0 +1,125 @@
// 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 (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
// overrideVisibleProducts temporarily installs an edition hook exposing the
// given static product list and restores the previous hooks on cleanup.
func overrideVisibleProducts(t *testing.T, products []string) {
t.Helper()
prev := edition.Get()
edition.Override(&edition.Hooks{
VisibleProducts: func() []string { return products },
})
t.Cleanup(func() { edition.Override(prev) })
}
// registerPluginProduct simulates a plugin's `AppendDynamicServer` call so
// the product ID ends up in DirectRuntimeProductIDs() without triggering
// network discovery.
func registerPluginProduct(t *testing.T, id, endpoint string) {
t.Helper()
AppendDynamicServer(market.ServerDescriptor{
Endpoint: endpoint,
CLI: market.CLIOverlay{
ID: id,
Command: id,
},
})
}
// TestHideNonDirectRuntimeCommands_PluginVisibleDespiteStaticVisibleProducts
// is a regression for the dws-wukong plugin-visibility bug: when an edition
// installs a static VisibleProducts hook (Wukong returns 40 hardcoded product
// IDs) and a plugin registers a new product via AppendDynamicServer
// (e.g. `conference-local`), the plugin command must stay visible because the
// dynamic registry takes precedence over the hook's static whitelist.
func TestHideNonDirectRuntimeCommands_PluginVisibleDespiteStaticVisibleProducts(t *testing.T) {
withCleanDynamicRegistry(t)
overrideVisibleProducts(t, []string{"calendar"})
registerPluginProduct(t, "conference-local", "stdio://plugin/conference-local")
root := &cobra.Command{Use: "dws"}
calendarCmd := &cobra.Command{Use: "calendar"}
pluginCmd := &cobra.Command{Use: "conference-local"}
bogusCmd := &cobra.Command{Use: "bogus-not-a-product"}
root.AddCommand(calendarCmd, pluginCmd, bogusCmd)
hideNonDirectRuntimeCommands(root)
if calendarCmd.Hidden {
t.Errorf("calendar (static VisibleProducts) must stay visible, got Hidden=true")
}
if pluginCmd.Hidden {
t.Errorf("conference-local (plugin-registered) must stay visible, got Hidden=true")
}
if !bogusCmd.Hidden {
t.Errorf("bogus-not-a-product must be hidden, got Hidden=false")
}
}
// TestVisibleMCPRootCommands_IncludesPluginProducts asserts that the help
// renderer surfaces plugin products in the "Discovered MCP Services" section
// and does not misclassify them as utility commands.
func TestVisibleMCPRootCommands_IncludesPluginProducts(t *testing.T) {
withCleanDynamicRegistry(t)
overrideVisibleProducts(t, []string{"calendar"})
registerPluginProduct(t, "conference-local", "stdio://plugin/conference-local")
root := &cobra.Command{Use: "dws"}
calendarCmd := &cobra.Command{Use: "calendar"}
pluginCmd := &cobra.Command{Use: "conference-local"}
authCmd := &cobra.Command{Use: "auth"}
root.AddCommand(calendarCmd, pluginCmd, authCmd)
services := visibleMCPRootCommands(root)
if !containsCommand(services, "conference-local") {
t.Errorf("visibleMCPRootCommands missing plugin command: %v", commandNames(services))
}
if !containsCommand(services, "calendar") {
t.Errorf("visibleMCPRootCommands missing static product: %v", commandNames(services))
}
utilities := visibleUtilityRootCommands(root)
if containsCommand(utilities, "conference-local") {
t.Errorf("visibleUtilityRootCommands must not include plugin command, got %v", commandNames(utilities))
}
if !containsCommand(utilities, "auth") {
t.Errorf("visibleUtilityRootCommands must include genuine utility command, got %v", commandNames(utilities))
}
}
func containsCommand(cmds []*cobra.Command, name string) bool {
for _, c := range cmds {
if c.Name() == name {
return true
}
}
return false
}
func commandNames(cmds []*cobra.Command) []string {
names := make([]string, 0, len(cmds))
for _, c := range cmds {
names = append(names, c.Name())
}
return names
}
+233
View File
@@ -0,0 +1,233 @@
// 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 (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
const (
// appTokenPrefix is the keychain account prefix for app-level tokens.
appTokenPrefix = "app-token:"
// tokenExpiryBuffer is the buffer time before actual expiry to consider
// the token as expired (same as user token: 5 minutes).
tokenExpiryBuffer = 5 * time.Minute
)
// AppTokenData stores the app-level access token obtained from the unified
// POST /v1.0/oauth2/accessToken endpoint. It works for both new-style
// (api.dingtalk.com) and legacy (oapi.dingtalk.com) APIs — the auth method
// (header vs query param) is chosen by the caller based on the target host.
type AppTokenData struct {
AccessToken string `json:"access_token,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
// Associated app credentials
ClientID string `json:"client_id"`
UpdatedAt time.Time `json:"updated_at"`
}
// IsTokenValid returns true if the access token has not expired.
func (d *AppTokenData) IsTokenValid() bool {
if d == nil || d.AccessToken == "" {
return false
}
return time.Now().Before(d.ExpiresAt.Add(-tokenExpiryBuffer))
}
// SaveAppTokenData persists AppTokenData to keychain, keyed by clientID.
func SaveAppTokenData(data *AppTokenData) error {
if data.ClientID == "" {
return fmt.Errorf("clientID is required for saving app token data")
}
data.UpdatedAt = time.Now()
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshal app token data: %w", err)
}
defer func() {
for i := range jsonData {
jsonData[i] = 0
}
}()
account := appTokenPrefix + data.ClientID
if err := keychain.Set(keychain.Service, account, string(jsonData)); err != nil {
return fmt.Errorf("save app token to keychain: %w", err)
}
return nil
}
// LoadAppTokenData loads AppTokenData from keychain for the given clientID.
// Returns nil, nil if no data exists.
func LoadAppTokenData(clientID string) (*AppTokenData, error) {
if clientID == "" {
return nil, fmt.Errorf("clientID is required for loading app token data")
}
account := appTokenPrefix + clientID
jsonStr, err := keychain.Get(keychain.Service, account)
if err != nil {
return nil, nil // Not found is not an error
}
if jsonStr == "" {
return nil, nil
}
var data AppTokenData
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
return nil, fmt.Errorf("parse app token data: %w", err)
}
return &data, nil
}
// DeleteAppTokenData removes AppTokenData from keychain for the given clientID.
func DeleteAppTokenData(clientID string) error {
if clientID == "" {
return nil
}
account := appTokenPrefix + clientID
return keychain.Remove(keychain.Service, account)
}
// --- Token Fetch Function ---
// FetchAppToken obtains an app-level access token from the unified endpoint:
//
// POST https://api.dingtalk.com/v1.0/oauth2/accessToken
// Body: {"appKey":"X","appSecret":"X"}
// Response: {"accessToken":"xxx","expireIn":7200}
//
// The same token works for both api.dingtalk.com and oapi.dingtalk.com.
func FetchAppToken(ctx context.Context, appKey, appSecret string) (token string, expiresIn int64, err error) {
body := map[string]string{
"appKey": appKey,
"appSecret": appSecret,
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return "", 0, fmt.Errorf("marshal request body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, AppAccessTokenURL, bytes.NewReader(bodyBytes))
if err != nil {
return "", 0, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := appTokenHTTPClient.Do(req)
if err != nil {
return "", 0, fmt.Errorf("fetching app token: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
if err != nil {
return "", 0, fmt.Errorf("reading response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", 0, fmt.Errorf("获取 app token 失败 (HTTP %d): %s", resp.StatusCode, truncateStr(string(respBody), 200))
}
var result struct {
AccessToken string `json:"accessToken"`
ExpireIn int64 `json:"expireIn"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", 0, fmt.Errorf("parsing app token response: %w", err)
}
if result.AccessToken == "" {
return "", 0, fmt.Errorf("app token 响应缺少 accessToken 字段")
}
if result.ExpireIn <= 0 {
result.ExpireIn = config.DefaultAccessTokenExpiry
}
return result.AccessToken, result.ExpireIn, nil
}
// --- AppTokenProvider ---
// AppTokenProvider manages app-level token acquisition, caching and auto-refresh.
type AppTokenProvider struct {
ConfigDir string
AppKey string
AppSecret string
HTTPClient *http.Client // injectable for testing; nil uses default
}
// GetToken returns a valid app-level access token.
// Tokens are cached in keychain and auto-refreshed when expired (with 5-min buffer).
func (p *AppTokenProvider) GetToken(ctx context.Context) (string, error) {
if p.AppKey == "" || p.AppSecret == "" {
return "", fmt.Errorf("缺少应用凭证 (appKey/appSecret),请通过 --client-id/--client-secret 指定或先执行 dws auth login")
}
// Load cached token data.
data, err := LoadAppTokenData(p.AppKey)
if err != nil {
data = nil // Treat load errors as cache miss
}
// Fast path: cached token is still valid.
if data != nil && data.IsTokenValid() {
return data.AccessToken, nil
}
// Slow path: fetch a new token.
if data == nil {
data = &AppTokenData{ClientID: p.AppKey}
}
now := time.Now()
token, expiresIn, fetchErr := FetchAppToken(ctx, p.AppKey, p.AppSecret)
if fetchErr != nil {
return "", fetchErr
}
data.AccessToken = token
data.ExpiresAt = now.Add(time.Duration(expiresIn) * time.Second)
// Persist updated token data.
if saveErr := SaveAppTokenData(data); saveErr != nil {
// Log but don't fail — token is still usable this time.
// Write to stderr so we don't corrupt stdout JSON output when piped
// into jq/grep/etc.
fmt.Fprintf(os.Stderr, "Warning: 无法缓存 app token: %v\n", saveErr)
}
return data.AccessToken, nil
}
// truncateStr truncates a string to maxLen characters.
func truncateStr(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
// appTokenHTTPClient is the default HTTP client for app token operations.
var appTokenHTTPClient = &http.Client{
Timeout: 15 * time.Second,
}
+185
View File
@@ -0,0 +1,185 @@
// 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 (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// mustJSONBody returns a *bytes.Buffer containing the JSON encoding of v, or fails the test.
func mustJSONBody(t *testing.T, v any) *bytes.Buffer {
t.Helper()
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(v); err != nil {
t.Fatalf("json encode: %v", err)
}
return &buf
}
func TestAppTokenData_IsTokenValid(t *testing.T) {
tests := []struct {
name string
data *AppTokenData
want bool
}{
{"nil data", nil, false},
{"empty token", &AppTokenData{}, false},
{"expired", &AppTokenData{
AccessToken: "tok",
ExpiresAt: time.Now().Add(-1 * time.Minute),
}, false},
{"within buffer", &AppTokenData{
AccessToken: "tok",
ExpiresAt: time.Now().Add(3 * time.Minute), // 3 min < 5 min buffer
}, false},
{"valid", &AppTokenData{
AccessToken: "tok",
ExpiresAt: time.Now().Add(10 * time.Minute),
}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.data.IsTokenValid(); got != tt.want {
t.Errorf("IsTokenValid() = %v, want %v", got, tt.want)
}
})
}
}
func TestAppTokenData_JSONRoundTrip(t *testing.T) {
original := &AppTokenData{
AccessToken: "app-tok-abc",
ExpiresAt: time.Now().Add(2 * time.Hour).Truncate(time.Second),
ClientID: "my-app-key",
UpdatedAt: time.Now().Truncate(time.Second),
}
data, err := json.Marshal(original)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded AppTokenData
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if decoded.AccessToken != original.AccessToken {
t.Errorf("AccessToken = %q, want %q", decoded.AccessToken, original.AccessToken)
}
if decoded.ClientID != original.ClientID {
t.Errorf("ClientID = %q, want %q", decoded.ClientID, original.ClientID)
}
}
func TestFetchAppToken_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
}
var body map[string]string
json.NewDecoder(r.Body).Decode(&body)
if body["appKey"] != "mykey" || body["appSecret"] != "mysecret" {
t.Errorf("got body %v, want appKey=mykey, appSecret=mysecret", body)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]any{
"accessToken": "app-tok-123",
"expireIn": 7200,
})
}))
defer srv.Close()
body := mustJSONBody(t, map[string]string{
"appKey": "mykey",
"appSecret": "mysecret",
})
resp, err := srv.Client().Post(srv.URL, "application/json", body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var result struct {
AccessToken string `json:"accessToken"`
ExpireIn int64 `json:"expireIn"`
}
json.NewDecoder(resp.Body).Decode(&result)
if result.AccessToken != "app-tok-123" {
t.Errorf("got token %q, want app-tok-123", result.AccessToken)
}
if result.ExpireIn != 7200 {
t.Errorf("got expireIn %d, want 7200", result.ExpireIn)
}
}
func TestFetchAppToken_EmptyToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]any{
"accessToken": "",
"expireIn": 7200,
})
}))
defer srv.Close()
body := mustJSONBody(t, map[string]string{
"appKey": "badkey",
"appSecret": "badsecret",
})
resp, err := srv.Client().Post(srv.URL, "application/json", body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var result struct {
AccessToken string `json:"accessToken"`
ExpireIn int64 `json:"expireIn"`
}
json.NewDecoder(resp.Body).Decode(&result)
if result.AccessToken != "" {
t.Errorf("expected empty accessToken, got %q", result.AccessToken)
}
}
func TestAppTokenProvider_GetToken_MissingCredentials(t *testing.T) {
provider := &AppTokenProvider{
ConfigDir: t.TempDir(),
AppKey: "",
AppSecret: "",
}
_, err := provider.GetToken(context.Background())
if err == nil {
t.Error("expected error for missing credentials")
}
}
func TestTruncateStr(t *testing.T) {
if got := truncateStr("hello", 10); got != "hello" {
t.Errorf("got %q, want hello", got)
}
if got := truncateStr("hello world", 5); got != "hello..." {
t.Errorf("got %q, want hello...", got)
}
}
+37
View File
@@ -0,0 +1,37 @@
// 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 (
"os"
"strings"
)
const (
// AgentCodeEnv is the sole per-spawn environment variable the host injects
// to declare "this process is driven by a third-party Agent host, render
// authorization UI yourselves".
AgentCodeEnv = "DINGTALK_DWS_AGENTCODE"
)
// HostOwnsPATFlow reports whether the current process is running under a
// third-party Agent host that will render the PAT authorization card
// itself. The sole trigger is AgentCodeEnv (DINGTALK_DWS_AGENTCODE) being
// non-empty. The CLI deliberately does not consult any other signal
// (DINGTALK_AGENT / DWS_CHANNEL / the wire claw-type header) for this
// decision so that server-side routing tags and the host-owned UI contract
// remain independent concerns.
func HostOwnsPATFlow() bool {
return strings.TrimSpace(os.Getenv(AgentCodeEnv)) != ""
}
+29 -7
View File
@@ -112,6 +112,10 @@ type DevicePollResponse struct {
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Data DevicePollData `json:"data"`
// Result is an alternate envelope some service versions return instead of
// (or alongside) Data. Always read poll fields via EffectiveData() rather
// than touching Data/Result directly.
Result DevicePollData `json:"result"`
}
type DevicePollData struct {
@@ -120,6 +124,20 @@ type DevicePollData struct {
FlowID string `json:"flowId,omitempty"`
}
// EffectiveData normalizes terminal poll responses that may carry payload
// fields under either `data` or `result`.
//
// Semantics are envelope-level rather than field-level: when Data includes a
// non-empty status, treat Data as the authoritative payload and return it
// unchanged; otherwise fall back to Result. This avoids mixing fields from two
// disagreeing envelopes into a Frankenstein result.
func (r DevicePollResponse) EffectiveData() DevicePollData {
if r.Data.Status != "" || r.Result.Status == "" {
return r.Data
}
return r.Result
}
type serviceResult struct {
Success bool `json:"success"`
Result json.RawMessage `json:"result"`
@@ -287,16 +305,19 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
}
// Persist app credentials (with secret) if using custom client credentials.
// MUST run BEFORE os.Setenv below to avoid env-matching short circuit.
oauthProvider.persistAppConfigIfNeeded()
// 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})
if !HasAppConfig(p.configDir) {
_ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
}
}
// Persist app credentials if using custom client credentials
oauthProvider.persistAppConfigIfNeeded()
return tokenData, nil
}
@@ -428,10 +449,11 @@ func (p *DeviceFlowProvider) waitForAuthorizationByFlowID(ctx context.Context, a
continue
}
switch pollResp.Data.Status {
pollData := pollResp.EffectiveData()
switch pollData.Status {
case StatusApproved:
dfPrintPollResult(p.output(), "authorized", i18n.T("授权成功!"))
return &DeviceTokenResponse{AuthCode: pollResp.Data.AuthCode}, nil
return &DeviceTokenResponse{AuthCode: pollData.AuthCode}, nil
case StatusPending:
dfPrintPollResult(p.output(), "pending", i18n.T("等待用户授权..."))
case StatusRejected:
@@ -441,7 +463,7 @@ func (p *DeviceFlowProvider) waitForAuthorizationByFlowID(ctx context.Context, a
_, _ = fmt.Fprintln(p.output(), "")
return nil, errors.New(i18n.T("设备授权码已过期"))
default:
dfPrintPollResult(p.output(), "unknown", fmt.Sprintf(i18n.T("未知状态: %s"), pollResp.Data.Status))
dfPrintPollResult(p.output(), "unknown", fmt.Sprintf(i18n.T("未知状态: %s"), pollData.Status))
}
}
}
+98
View File
@@ -143,6 +143,104 @@ func TestWaitForAuthorizationSucceedsAfterPending(t *testing.T) {
}
}
func TestWaitForAuthorizationAcceptsResultEnvelope(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.MethodGet {
t.Fatalf("method = %s, want GET", r.Method)
}
if calls.Add(1) <= 2 {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"result": map[string]string{"status": "PENDING"},
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"result": map[string]string{
"status": "APPROVED",
"authCode": "final-auth-code",
},
})
}))
defer server.Close()
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
provider.Output = io.Discard
provider.SetTerminalBaseURL(server.URL)
resp, err := provider.waitForAuthorization(context.Background(), &DeviceAuthResponse{
FlowID: "test-flow-id-result",
ExpiresIn: 10,
Interval: 1,
})
if err != nil {
t.Fatalf("waitForAuthorization() error = %v", err)
}
if resp.AuthCode != "final-auth-code" {
t.Fatalf("auth code = %q, want final-auth-code", resp.AuthCode)
}
if calls.Load() != 3 {
t.Fatalf("poll calls = %d, want 3", calls.Load())
}
}
func TestDevicePollResponseEffectiveData_FallsBackToResultEnvelope(t *testing.T) {
t.Parallel()
resp := DevicePollResponse{
Success: true,
Result: DevicePollData{
Status: "APPROVED",
AuthCode: "auth-from-result",
FlowID: "flow-from-result",
},
}
effective := resp.EffectiveData()
if effective.Status != "APPROVED" {
t.Fatalf("effective.Status = %q, want APPROVED", effective.Status)
}
if effective.AuthCode != "auth-from-result" {
t.Fatalf("effective.AuthCode = %q, want auth-from-result", effective.AuthCode)
}
if effective.FlowID != "flow-from-result" {
t.Fatalf("effective.FlowID = %q, want flow-from-result", effective.FlowID)
}
}
func TestDevicePollResponseEffectiveData_DataEnvelopeWinsAsWholePayload(t *testing.T) {
t.Parallel()
resp := DevicePollResponse{
Success: true,
Data: DevicePollData{
Status: "PENDING",
},
Result: DevicePollData{
Status: "APPROVED",
AuthCode: "auth-from-result",
FlowID: "flow-from-result",
},
}
effective := resp.EffectiveData()
if effective.Status != "PENDING" {
t.Fatalf("effective.Status = %q, want PENDING", effective.Status)
}
if effective.AuthCode != "" {
t.Fatalf("effective.AuthCode = %q, want empty because Data envelope wins as a whole", effective.AuthCode)
}
if effective.FlowID != "" {
t.Fatalf("effective.FlowID = %q, want empty because Data envelope wins as a whole", effective.FlowID)
}
}
func TestWaitForAuthorizationFallsBackToDeviceCodeWhenFlowIDMissing(t *testing.T) {
t.Parallel()
+6 -5
View File
@@ -106,6 +106,12 @@ const (
MCPOAuthTokenPath = "/oauth2/getToken"
MCPRefreshTokenPath = "/oauth2/refreshToken"
MCPRevokeTokenPath = "/oauth2/revokeToken"
// App-level access token endpoints (for dws api raw calls).
// AppAccessTokenURL is the unified app-level access token endpoint.
// POST with {"appKey":"X","appSecret":"X"} → {"accessToken":"...","expireIn":7200}
AppAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
)
// GetTerminalBaseURL returns the terminal base URL with priority:
@@ -290,11 +296,6 @@ func getRuntimeCredentials() (clientID, clientSecret string) {
return runtimeClientID, runtimeClientSecret
}
// getEnvClientID returns the environment variable client ID if set.
func getEnvClientID() string {
return os.Getenv("DWS_CLIENT_ID")
}
// getDefaultConfigDir returns the default configuration directory.
// Priority: DWS_CONFIG_DIR env var > ~/.dws
func getDefaultConfigDir() string {
+14 -7
View File
@@ -85,6 +85,10 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
if p.logger != nil {
p.logger.Debug("access_token still valid, skipping login")
}
// Even on early return, persist custom app credentials if provided
// via --client-id/--client-secret flags. Without this, the flags
// are only in runtime globals and lost when the process exits.
p.persistAppConfigIfNeeded()
return data, nil
}
// Case 2: refresh using refresh_token (with lock to prevent concurrent refresh).
@@ -94,6 +98,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
}
refreshed, rErr := p.lockedRefresh(ctx)
if rErr == nil {
p.persistAppConfigIfNeeded()
return refreshed, nil
}
if p.logger != nil {
@@ -496,16 +501,19 @@ continueLogin:
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
}
// Persist app credentials (with secret) if using custom client credentials.
// MUST run BEFORE os.Setenv below to avoid env-matching short circuit.
p.persistAppConfigIfNeeded()
// 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})
if !HasAppConfig(p.configDir) {
_ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
}
}
// Persist app credentials if using custom client credentials
p.persistAppConfigIfNeeded()
return tokenData, nil
}
@@ -627,9 +635,8 @@ func (p *OAuthProvider) persistAppConfigIfNeeded() {
return
}
// Only persist if they differ from environment/default values
envID := getEnvClientID()
if clientID == envID || clientID == DefaultClientID {
// Skip if using default placeholder credentials
if clientID == DefaultClientID {
return
}
+123 -2
View File
@@ -24,6 +24,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/editionmerge"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
@@ -196,14 +197,23 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
return FixtureLoader{Path: fixturePath}.Load(ctx)
}
// Priority: explicit test override > edition-specific discovery URL >
// open-source default. For Wukong this pulls the runtime catalog fetch
// onto the same Portal endpoint that loadDynamicCommands already uses,
// eliminating the historical split where the command tree came from
// Wukong Portal while runtime endpoint resolution silently read the
// open-source Market cache (see fix-wukong-endpoint-partition plan).
baseURL := DefaultMarketBaseURL
if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" {
baseURL = editionURL
}
if l.CatalogBaseURLOverride != "" {
baseURL = l.CatalogBaseURLOverride
}
cacheDir, _ := l.lookup(CacheDirEnv)
store := cache.NewStore(cacheDir)
partition := config.DefaultPartition
partition := config.EditionPartition(edition.Get().Name)
// Cache-first: if a cached catalog is available, use it immediately.
// Startup command construction should not block on synchronous discovery
@@ -223,6 +233,15 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
}
if !hasAuth {
// Cache / discovery both unreachable without credentials — fall back
// to the edition's SupplementServers / FallbackServers hook so that
// hardcoded overlay commands can still resolve an endpoint via the
// returned catalog. Without this an unauthenticated cold start
// produces DegradedUnauthenticated and every hardcoded command
// fails even when the edition carries its own static endpoint map.
if fb := fallbackRuntimeServers(); len(fb) > 0 {
return ir.BuildCatalog(fb), nil
}
return ir.Catalog{}, newCatalogDegraded(DegradedUnauthenticated, 0)
}
@@ -247,6 +266,9 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
if cached.Available && len(cached.Catalog.Products) > 0 {
return cached.Catalog, nil
}
if fb := fallbackRuntimeServers(); len(fb) > 0 {
return ir.BuildCatalog(fb), nil
}
return ir.Catalog{}, newCatalogDegraded(DegradedMarketUnreachable, 0)
}
@@ -287,6 +309,9 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
if cached.Available && len(cached.Catalog.Products) > 0 {
return cached.Catalog, nil
}
if fb := fallbackRuntimeServers(); len(fb) > 0 {
return ir.BuildCatalog(fb), nil
}
return ir.Catalog{}, newCatalogDegraded(DegradedRuntimeAllFailed, len(servers))
}
@@ -305,6 +330,7 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
runtimeServers = append(runtimeServers, runtimeServer)
}
}
runtimeServers = appendSupplementRuntimeServers(runtimeServers)
return ir.BuildCatalog(runtimeServers), nil
}
@@ -313,15 +339,28 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
// window, the returned state asks the caller to try live discovery before
// trusting the cache as current truth.
func (l EnvironmentLoader) loadFromCache(store *cache.Store) cachedCatalogState {
partition := config.DefaultPartition
partition := config.EditionPartition(edition.Get().Name)
regSnap, freshness, err := store.LoadRegistry(partition)
if err != nil || len(regSnap.Servers) == 0 {
// No cached registry. Still honour the edition's SupplementServers
// hook so that hardcoded overlay commands whose products are not
// part of the Portal envelope (Wukong gray-release in particular)
// can resolve an endpoint via the catalog path as well.
if supplement := supplementRuntimeServers(nil); len(supplement) > 0 {
return cachedCatalogState{
Catalog: ir.BuildCatalog(supplement),
Registry: regSnap,
Available: true,
NeedsRevalidate: true,
}
}
return cachedCatalogState{}
}
now := store.Now().UTC()
needsRevalidate := freshness == cache.FreshnessStale || cache.ShouldRevalidate(now, regSnap.SavedAt)
runtimeServers := make([]discovery.RuntimeServer, 0, len(regSnap.Servers))
existing := make(map[string]bool, len(regSnap.Servers))
for _, server := range regSnap.Servers {
toolsSnap, toolsFreshness, toolsErr := store.LoadTools(partition, server.Key)
if toolsErr != nil || toolsFreshness != cache.FreshnessFresh {
@@ -335,10 +374,17 @@ func (l EnvironmentLoader) loadFromCache(store *cache.Store) cachedCatalogState
Source: "fresh_cache",
Degraded: false,
})
if id := server.CLI.ID; id != "" {
existing[id] = true
}
if server.Key != "" {
existing[server.Key] = true
}
}
if len(runtimeServers) != len(regSnap.Servers) {
needsRevalidate = true
}
runtimeServers = append(runtimeServers, supplementRuntimeServers(existing)...)
return cachedCatalogState{
Catalog: ir.BuildCatalog(runtimeServers),
Registry: regSnap,
@@ -347,6 +393,81 @@ func (l EnvironmentLoader) loadFromCache(store *cache.Store) cachedCatalogState
}
}
// supplementRuntimeServers materialises the edition.SupplementServers hook
// as discovery.RuntimeServer values, skipping IDs that already appear in
// the discovery result. The returned servers carry no tools — they exist
// only so catalog.FindProduct can resolve an endpoint; tool validation
// for these products is expected to fall through to directRuntimeEndpoint.
func supplementRuntimeServers(existing map[string]bool) []discovery.RuntimeServer {
fn := edition.Get().SupplementServers
if fn == nil {
return nil
}
sup := fn()
if len(sup) == 0 {
return nil
}
out := make([]discovery.RuntimeServer, 0, len(sup))
for _, s := range sup {
if s.ID == "" {
continue
}
if existing != nil && existing[s.ID] {
continue
}
out = append(out, discovery.RuntimeServer{
Server: editionmerge.ToDescriptor(s, "edition_supplement"),
Source: "edition_supplement",
Degraded: false,
})
}
return out
}
// fallbackRuntimeServers materialises the edition.FallbackServers hook,
// additionally folding in SupplementServers entries the hook omits.
// Used when every other discovery avenue failed.
func fallbackRuntimeServers() []discovery.RuntimeServer {
fn := edition.Get().FallbackServers
if fn == nil {
return supplementRuntimeServers(nil)
}
fb := fn()
if len(fb) == 0 {
return supplementRuntimeServers(nil)
}
existing := make(map[string]bool, len(fb))
out := make([]discovery.RuntimeServer, 0, len(fb))
for _, s := range fb {
if s.ID == "" {
continue
}
existing[s.ID] = true
out = append(out, discovery.RuntimeServer{
Server: editionmerge.ToDescriptor(s, "edition_fallback"),
Source: "edition_fallback",
Degraded: false,
})
}
out = append(out, supplementRuntimeServers(existing)...)
return out
}
// appendSupplementRuntimeServers merges supplement entries into a live
// discovery result, deduplicating against existing IDs.
func appendSupplementRuntimeServers(servers []discovery.RuntimeServer) []discovery.RuntimeServer {
existing := make(map[string]bool, len(servers))
for _, s := range servers {
if id := s.Server.CLI.ID; id != "" {
existing[id] = true
}
if s.Server.Key != "" {
existing[s.Server.Key] = true
}
}
return append(servers, supplementRuntimeServers(existing)...)
}
func (l EnvironmentLoader) lookup(key string) (string, bool) {
if l.LookupEnv == nil {
return "", false
+164
View File
@@ -0,0 +1,164 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cli
import (
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// setEdition overrides the active edition hooks for the duration of the test.
func setEdition(t *testing.T, h *edition.Hooks) {
t.Helper()
prev := edition.Get()
edition.Override(h)
t.Cleanup(func() { edition.Override(prev) })
}
func seedRegistryCache(t *testing.T, store *cache.Store, partition string, savedAt time.Time, servers []market.ServerDescriptor) {
t.Helper()
if err := store.SaveRegistry(partition, cache.RegistrySnapshot{
SavedAt: savedAt,
Servers: servers,
}); err != nil {
t.Fatalf("SaveRegistry(%q) error = %v", partition, err)
}
for _, server := range servers {
if err := store.SaveTools(partition, server.Key, cache.ToolsSnapshot{
SavedAt: savedAt,
ServerKey: server.Key,
}); err != nil {
t.Fatalf("SaveTools(%q) error = %v", server.Key, err)
}
}
}
// TestLoadFromCache_UsesEditionPartition verifies that loadFromCache reads
// from the edition-specific partition (wukong/default) instead of the
// historical hardcoded default/default. Before the fix, an entry written to
// wukong/default was invisible to the runtime catalog loader — which is
// exactly what caused `dws conference meeting create` to report
// endpoint_not_resolved while todo succeeded (the open-source Market cache
// happened to carry todo).
func TestLoadFromCache_UsesEditionPartition(t *testing.T) {
setEdition(t, &edition.Hooks{Name: "wukong"})
root := t.TempDir()
now := time.Date(2026, 4, 28, 0, 0, 0, 0, time.UTC)
store := cache.NewStore(root)
store.Now = func() time.Time { return now }
seedRegistryCache(t, store, "wukong/default", now, []market.ServerDescriptor{
{
Key: "conference",
DisplayName: "会议",
Endpoint: "https://example.invalid/conference",
CLI: market.CLIOverlay{
ID: "conference",
Command: "conference",
},
},
})
loader := EnvironmentLoader{}
state := loader.loadFromCache(store)
if !state.Available {
t.Fatalf("expected cached state available; got %+v", state)
}
if _, ok := state.Catalog.FindProduct("conference"); !ok {
t.Fatalf("conference not in catalog; products=%v", productIDs(state.Catalog.Products))
}
}
// TestLoadFromCache_IgnoresDefaultPartitionForOverlay asserts the cross-partition
// leak is gone: writing servers under default/default while the edition is
// Wukong must NOT surface in the runtime catalog. Previously this path was
// the accidental fallback that let `dws todo` work on a gray-released host.
func TestLoadFromCache_IgnoresDefaultPartitionForOverlay(t *testing.T) {
setEdition(t, &edition.Hooks{Name: "wukong"})
root := t.TempDir()
now := time.Date(2026, 4, 28, 0, 0, 0, 0, time.UTC)
store := cache.NewStore(root)
store.Now = func() time.Time { return now }
seedRegistryCache(t, store, "default/default", now, []market.ServerDescriptor{
{
Key: "todo",
DisplayName: "待办",
Endpoint: "https://example.invalid/todo",
CLI: market.CLIOverlay{
ID: "todo",
Command: "todo",
},
},
})
loader := EnvironmentLoader{}
state := loader.loadFromCache(store)
if state.Available {
if _, ok := state.Catalog.FindProduct("todo"); ok {
t.Fatalf("todo leaked from default/default into wukong catalog (partition isolation regressed)")
}
}
}
// TestLoadFromCache_OpenEdition_UsesDefaultPartition keeps the open-source
// core behaviour intact: with edition.Name == "" (zero value), loadFromCache
// must still read default/default.
func TestLoadFromCache_OpenEdition_UsesDefaultPartition(t *testing.T) {
setEdition(t, &edition.Hooks{})
root := t.TempDir()
now := time.Date(2026, 4, 28, 0, 0, 0, 0, time.UTC)
store := cache.NewStore(root)
store.Now = func() time.Time { return now }
seedRegistryCache(t, store, "default/default", now, []market.ServerDescriptor{
{
Key: "doc",
DisplayName: "文档",
Endpoint: "https://example.invalid/doc",
CLI: market.CLIOverlay{
ID: "doc",
Command: "doc",
},
},
})
loader := EnvironmentLoader{}
state := loader.loadFromCache(store)
if !state.Available {
t.Fatalf("expected cached state available for open edition; got %+v", state)
}
if _, ok := state.Catalog.FindProduct("doc"); !ok {
t.Fatalf("doc not in catalog; products=%v", productIDs(state.Catalog.Products))
}
}
func productIDs(products []ir.CanonicalProduct) []string {
ids := make([]string, 0, len(products))
for _, p := range products {
ids = append(ids, p.ID)
}
return ids
}
+151
View File
@@ -0,0 +1,151 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cli
import (
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// TestLoadFromCache_SupplementFillsGaps simulates the Wukong gray-release
// scenario: the Portal envelope only carries `live`, but the edition's
// SupplementServers hook ships the hardcoded endpoints for `conference` and
// `todo`. The resulting catalog must expose all three so runtime endpoint
// resolution does not depend on the historical default-partition accident.
func TestLoadFromCache_SupplementFillsGaps(t *testing.T) {
setEdition(t, &edition.Hooks{
Name: "wukong",
SupplementServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{ID: "conference", Name: "会议", Endpoint: "https://example.invalid/conference"},
{ID: "todo", Name: "待办", Endpoint: "https://example.invalid/todo"},
// Duplicate of the discovery entry — MUST be overridden by
// the discovery entry (discovery wins on ID collision).
{ID: "live", Name: "直播(supplement)", Endpoint: "https://example.invalid/overridden"},
}
},
})
root := t.TempDir()
now := time.Date(2026, 4, 28, 0, 0, 0, 0, time.UTC)
store := cache.NewStore(root)
store.Now = func() time.Time { return now }
liveEndpoint := "https://example.invalid/live"
seedRegistryCache(t, store, "wukong/default", now, []market.ServerDescriptor{
{
Key: "live",
DisplayName: "直播",
Endpoint: liveEndpoint,
CLI: market.CLIOverlay{
ID: "live",
Command: "live",
},
},
})
loader := EnvironmentLoader{}
state := loader.loadFromCache(store)
if !state.Available {
t.Fatalf("expected cached state available; got %+v", state)
}
wantIDs := map[string]string{
"conference": "https://example.invalid/conference",
"todo": "https://example.invalid/todo",
"live": liveEndpoint, // discovery wins, NOT the supplement's overridden URL
}
for id, wantEndpoint := range wantIDs {
product, ok := state.Catalog.FindProduct(id)
if !ok {
t.Errorf("catalog missing product %q; have %v", id, productIDs(state.Catalog.Products))
continue
}
if product.Endpoint != wantEndpoint {
t.Errorf("product %q endpoint = %q, want %q", id, product.Endpoint, wantEndpoint)
}
}
}
// TestLoadFromCache_EmptyRegistry_StillExposesSupplement covers the cold-start
// gray-release case: no cached registry at all, but the edition still knows
// about a set of hardcoded products. Those should be exposed via the catalog
// so `dws foo bar` does not fail with endpoint_not_resolved on first run.
func TestLoadFromCache_EmptyRegistry_StillExposesSupplement(t *testing.T) {
setEdition(t, &edition.Hooks{
Name: "wukong",
SupplementServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{ID: "conference", Name: "会议", Endpoint: "https://example.invalid/conference"},
}
},
})
root := t.TempDir()
now := time.Date(2026, 4, 28, 0, 0, 0, 0, time.UTC)
store := cache.NewStore(root)
store.Now = func() time.Time { return now }
loader := EnvironmentLoader{}
state := loader.loadFromCache(store)
if !state.Available {
t.Fatalf("expected cached state available via supplement; got %+v", state)
}
if _, ok := state.Catalog.FindProduct("conference"); !ok {
t.Fatalf("supplement did not surface conference into catalog; products=%v", productIDs(state.Catalog.Products))
}
if !state.NeedsRevalidate {
t.Errorf("NeedsRevalidate should be true when only supplement is available")
}
}
// TestFallbackRuntimeServers_UsedWhenDiscoveryFailsWithoutCache exercises
// the worst-case path: no cached registry AND no live discovery (embedded
// scenario where AuthTokenFunc returns empty). FallbackServers must still
// surface a usable catalog.
func TestFallbackRuntimeServers_UsedWhenDiscoveryFailsWithoutCache(t *testing.T) {
setEdition(t, &edition.Hooks{
Name: "wukong",
FallbackServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{ID: "conference", Name: "会议", Endpoint: "https://fallback.invalid/conference"},
}
},
SupplementServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{ID: "extra", Name: "Extra", Endpoint: "https://fallback.invalid/extra"},
}
},
})
rs := fallbackRuntimeServers()
if len(rs) != 2 {
t.Fatalf("fallbackRuntimeServers() len = %d, want 2 (fallback + non-overlapping supplement); got %v", len(rs), rs)
}
ids := make(map[string]bool, len(rs))
for _, r := range rs {
ids[r.Server.CLI.ID] = true
}
for _, want := range []string{"conference", "extra"} {
if !ids[want] {
t.Errorf("fallbackRuntimeServers() missing %q; have %v", want, ids)
}
}
}
+87
View File
@@ -0,0 +1,87 @@
// 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 editionmerge converts edition.ServerInfo hooks into
// market.ServerDescriptor values and merges them into discovery results.
//
// This package exists so both internal/cli (runtime catalog loader) and
// internal/app (command-tree loader) can apply the edition's
// SupplementServers / FallbackServers hooks consistently against the same
// discovery pipeline, instead of the hooks being wired only at the
// command-tree layer. Keeping the logic here avoids an import cycle
// between internal/cli ↔ internal/app.
package editionmerge
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// MergeSupplement returns servers augmented with the active edition's
// SupplementServers hook. Discovery entries always win on ID collision —
// the supplement only fills gaps that discovery did not cover.
func MergeSupplement(servers []market.ServerDescriptor) []market.ServerDescriptor {
fn := edition.Get().SupplementServers
if fn == nil {
return servers
}
existing := make(map[string]bool, len(servers))
for _, s := range servers {
if id := s.CLI.ID; id != "" {
existing[id] = true
}
if s.Key != "" {
existing[s.Key] = true
}
}
for _, sup := range fn() {
if sup.ID == "" || existing[sup.ID] {
continue
}
servers = append(servers, ToDescriptor(sup, "edition_supplement"))
}
return servers
}
// FallbackToDescriptors converts the edition's FallbackServers hook into
// market.ServerDescriptor values. Callers should only invoke this when
// live discovery returned zero servers and the cache is also empty.
func FallbackToDescriptors(servers []edition.ServerInfo) []market.ServerDescriptor {
out := make([]market.ServerDescriptor, 0, len(servers))
for _, s := range servers {
out = append(out, ToDescriptor(s, "edition_fallback"))
}
return out
}
// ToDescriptor is the shared conversion from edition.ServerInfo to the
// market descriptor shape expected by downstream consumers.
//
// Source carries the origin tag for diagnostics / metrics. Supplement and
// fallback entries intentionally carry no ToolOverrides — that keeps
// internal/compat.BuildDynamicCommands from materialising parallel
// command trees for products already owned by hardcoded overlays (see
// internal/compat/dynamic_commands.go's CLIOverlay gate).
func ToDescriptor(s edition.ServerInfo, source string) market.ServerDescriptor {
return market.ServerDescriptor{
Key: s.ID,
DisplayName: s.Name,
Endpoint: s.Endpoint,
Source: source,
CLI: market.CLIOverlay{
ID: s.ID,
Command: s.ID,
Prefixes: s.Prefixes,
},
}
}
+123
View File
@@ -0,0 +1,123 @@
// 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 editionmerge
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func restoreEdition(t *testing.T) {
t.Helper()
prev := edition.Get()
t.Cleanup(func() { edition.Override(prev) })
}
func TestMergeSupplement_DiscoveryWinsOnCollision(t *testing.T) {
restoreEdition(t)
edition.Override(&edition.Hooks{
SupplementServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{ID: "conference", Name: "会议", Endpoint: "https://hardcoded/conference"},
{ID: "doc", Name: "文档(overridden)", Endpoint: "https://hardcoded/doc"},
}
},
})
servers := []market.ServerDescriptor{
{
Key: "doc",
DisplayName: "文档",
Endpoint: "https://live/doc",
CLI: market.CLIOverlay{ID: "doc", Command: "doc"},
},
}
merged := MergeSupplement(servers)
if len(merged) != 2 {
t.Fatalf("merged len = %d, want 2", len(merged))
}
byID := make(map[string]market.ServerDescriptor, len(merged))
for _, m := range merged {
byID[m.CLI.ID] = m
}
if got := byID["doc"].Endpoint; got != "https://live/doc" {
t.Errorf("doc endpoint = %q, want live endpoint (discovery wins)", got)
}
if got := byID["conference"].Endpoint; got != "https://hardcoded/conference" {
t.Errorf("conference endpoint = %q, want supplement endpoint", got)
}
if got := byID["conference"].Source; got != "edition_supplement" {
t.Errorf("conference Source = %q, want edition_supplement", got)
}
}
func TestMergeSupplement_NilHookIsNoop(t *testing.T) {
restoreEdition(t)
edition.Override(&edition.Hooks{})
servers := []market.ServerDescriptor{
{Key: "doc", DisplayName: "文档", Endpoint: "https://live/doc",
CLI: market.CLIOverlay{ID: "doc", Command: "doc"}},
}
merged := MergeSupplement(servers)
if len(merged) != 1 {
t.Fatalf("merged len = %d, want 1 (no supplement hook registered)", len(merged))
}
}
func TestMergeSupplement_EmptyIDSkipped(t *testing.T) {
restoreEdition(t)
edition.Override(&edition.Hooks{
SupplementServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{ID: "", Name: "empty", Endpoint: "https://example.invalid/empty"},
{ID: "valid", Name: "valid", Endpoint: "https://example.invalid/valid"},
}
},
})
merged := MergeSupplement(nil)
if len(merged) != 1 {
t.Fatalf("merged len = %d, want 1 (empty ID must be skipped)", len(merged))
}
if merged[0].CLI.ID != "valid" {
t.Errorf("merged[0].CLI.ID = %q, want valid", merged[0].CLI.ID)
}
}
func TestFallbackToDescriptors(t *testing.T) {
got := FallbackToDescriptors([]edition.ServerInfo{
{ID: "conference", Name: "会议", Endpoint: "https://example.invalid/conference", Prefixes: []string{"conference", "meeting"}},
})
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
g := got[0]
if g.CLI.ID != "conference" || g.CLI.Command != "conference" {
t.Errorf("CLI overlay not wired: %+v", g.CLI)
}
if g.Source != "edition_fallback" {
t.Errorf("Source = %q, want edition_fallback", g.Source)
}
if len(g.CLI.ToolOverrides) != 0 {
t.Errorf("fallback descriptor must not carry ToolOverrides; got %v", g.CLI.ToolOverrides)
}
}
+5 -1
View File
@@ -65,6 +65,10 @@ func (e *Error) Unwrap() error {
type Option func(*Error)
// ExitCode returns the documented process exit code for the error category.
// exit=4 is reserved exclusively for PATError (see internal/errors/pat.go
// ExitCodePermission and the exit-code table in docs/reference.md);
// Discovery therefore uses 6 so hosts can tell "catalog lookup broke"
// apart from "PAT permission insufficient".
func (e *Error) ExitCode() int {
switch e.Category {
case CategoryAPI:
@@ -74,7 +78,7 @@ func (e *Error) ExitCode() int {
case CategoryValidation:
return 3
case CategoryDiscovery:
return 4
return 6
default:
return 5
}
+1 -1
View File
@@ -178,7 +178,7 @@ func TestExitCode_AllCategories(t *testing.T) {
{CategoryAPI, 1},
{CategoryAuth, 2},
{CategoryValidation, 3},
{CategoryDiscovery, 4},
{CategoryDiscovery, 6},
{CategoryInternal, 5},
}
for _, tt := range tests {
+1 -1
View File
@@ -29,7 +29,7 @@ func TestExitCodeByCategory(t *testing.T) {
{err: NewAPI("api"), want: 1},
{err: NewAuth("auth"), want: 2},
{err: NewValidation("validation"), want: 3},
{err: NewDiscovery("discovery"), want: 4},
{err: NewDiscovery("discovery"), want: 6},
{err: NewInternal("internal"), want: 5},
{err: stderrors.New("plain"), want: 5},
}
+175 -29
View File
@@ -18,15 +18,95 @@ import (
stderrors "errors"
"fmt"
"strings"
"sync"
)
// hostControlProvider returns the host-owned clawType for the current
// process, or empty string when CLI is in default (CLI-owned) mode.
// Injected lazily via SetHostControlProvider to avoid an
// internal/errors → internal/auth import cycle.
//
// Access is serialized by hostControlMu so that tests can swap the provider
// without triggering the race detector against parallel classifier callers.
var (
hostControlMu sync.RWMutex
hostControlProvider func() string
patBrowserMu sync.RWMutex
patBrowserProvider func() bool
)
// SetHostControlProvider wires up the classifier's hostControl injection.
// It MUST be called once during CLI bootstrap (e.g. from internal/app
// init()) so that the first cleanPATJSON call observes a valid provider.
// Passing nil disables injection (useful for isolated tests).
func SetHostControlProvider(fn func() string) {
hostControlMu.Lock()
defer hostControlMu.Unlock()
hostControlProvider = fn
}
// SetPATOpenBrowserProvider wires the PAT JSON serializer to the current
// browser policy. Passing nil restores the open-source fallback (true).
func SetPATOpenBrowserProvider(fn func() bool) {
patBrowserMu.Lock()
defer patBrowserMu.Unlock()
patBrowserProvider = fn
}
// PATOpenBrowserValue returns the effective browser-open recommendation to
// embed in PAT JSON payloads. The open-source fallback is true to preserve
// historical behavior when no provider is wired.
func PATOpenBrowserValue() bool {
patBrowserMu.RLock()
provider := patBrowserProvider
patBrowserMu.RUnlock()
if provider == nil {
return true
}
return provider()
}
// HostControlBlock returns the canonical hostControl map injected into
// PAT stderr JSON when the CLI is operating in host-owned mode, or nil
// when it is not. The returned map is safe for the caller to mutate
// because a new map is constructed on each call.
//
// callbackOwner is kept as a legacy compatibility key for hosts that adopted
// it before the contract converged on the hostControl single injection point.
func HostControlBlock() map[string]any {
hostControlMu.RLock()
provider := hostControlProvider
hostControlMu.RUnlock()
if provider == nil {
return nil
}
claw := provider()
if claw == "" {
return nil
}
return map[string]any{
"clawType": claw,
"callbackOwner": "host",
"mode": "host",
"pollingOwner": "host",
"retryOwner": "host",
}
}
// 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.
// wrapping. The host application parses the JSON to display its own
// authorization UI. The wire schema is fixed: a single-line, directly
// json.Unmarshal-able payload of the form
// {"success":false,"code":<frozen enum>,"data":{...}}.
//
// When the payload includes data.uri, that URL is the authoritative
// server-provided authorization link. Hosts must treat it as opaque and open
// it verbatim instead of parsing and reconstructing it locally, because
// required parameters may live in query, encoded hash, or fragment sections.
type PATError struct {
RawJSON string
}
@@ -49,9 +129,21 @@ var patNoPermissionCodes = map[string]bool{
}
// patAuthRequiredCodes are error codes that trigger the PAT authorization
// flow (e.g. the server auto-created a CLI app and returned auth details).
// flow (e.g. the server auto-created a CLI app and returned auth details,
// or the caller's OAuth token lacks a scope that must be re-acquired via
// `dws auth login --scope <missing>`).
//
// Keep keys in alphabetical order so diffs are stable. Both codes below are
// part of the frozen PAT-family selector and MUST be surfaced as *PATError
// (exit=4) so hosts can act on them:
// - AGENT_CODE_NOT_EXISTS: data.agentCode tells the host which agent
// registration is missing.
// - PAT_SCOPE_AUTH_REQUIRED: data.missingScope tells the host which
// OAuth scope to re-acquire via
// `dws auth login --scope <data.missingScope>`.
var patAuthRequiredCodes = map[string]bool{
"AGENT_CODE_NOT_EXISTS": true,
"AGENT_CODE_NOT_EXISTS": true,
"PAT_SCOPE_AUTH_REQUIRED": true,
}
// IsPATError reports whether err is a *PATError.
@@ -65,6 +157,35 @@ func IsPATNoPermissionCode(code string) bool {
return patNoPermissionCodes[code]
}
// errCodeKeys is the canonical priority order in which we look up
// upstream error code fields. Servers historically rotated between camel
// and snake case; we accept all three and pick the first that resolves to
// a recognised value.
var errCodeKeys = []string{"code", "errorCode", "error_code"}
// lookupCodeIn returns the first value in body[errCodeKeys] that is a
// non-empty string AND is a member of accept. Used by the PAT and DWS
// gateway classifiers, which differ only in their accept-set.
func lookupCodeIn(body map[string]any, accept map[string]bool) (string, bool) {
for _, key := range errCodeKeys {
if code, ok := body[key].(string); ok && accept[code] {
return code, true
}
}
return "", false
}
// getPATErrorCode extracts any PAT-intercept code from a map. PAT
// intercepts include both permission denials and auth-required selectors:
// callers on the text/tool-result path must preserve both families as
// *PATError so exit=4 + raw stderr JSON survives all the way to the host/CLI.
func getPATErrorCode(body map[string]any) (string, bool) {
if code, ok := lookupCodeIn(body, patNoPermissionCodes); ok {
return code, true
}
return lookupCodeIn(body, patAuthRequiredCodes)
}
// ---- DWS gateway auth errors (shared between PAT & general auth) ----------
// dwsGatewayErrors is the set of DWS gateway-level auth error codes.
@@ -73,20 +194,18 @@ var dwsGatewayErrors = map[string]bool{
"DWS_AUTH_SERVICE_FAILED": true,
}
// getDWSGatewayErrorCode extracts a DWS gateway error code from errBody
// (supports both errorCode and error_code field names).
// getDWSGatewayErrorCode extracts a DWS gateway error code from errBody.
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
return lookupCodeIn(errBody, dwsGatewayErrors)
}
// isNotLoggedInError checks if the error body indicates missing authentication.
func isNotLoggedInError(body map[string]any) bool {
if errMsg, ok := body["error"].(string); ok {
for _, key := range []string{"error", "message", "errorMsg"} {
errMsg, ok := body[key].(string)
if !ok {
continue
}
if strings.Contains(errMsg, "Missing service_id or access_key") {
return true
}
@@ -124,10 +243,8 @@ func ClassifyToolResultContent(content map[string]any) error {
WithHint(authExpiredHint()),
)
}
for _, key := range []string{"code", "errorCode"} {
if code, ok := content[key].(string); ok && patNoPermissionCodes[code] {
return &PATError{RawJSON: cleanPATJSON(content, code)}
}
if code, ok := getPATErrorCode(content); ok {
return &PATError{RawJSON: cleanPATJSON(content, code)}
}
return nil
}
@@ -158,10 +275,8 @@ func ClassifyMCPResponseText(text string) error {
)
}
for _, key := range []string{"code", "errorCode"} {
if code, ok := body[key].(string); ok && patNoPermissionCodes[code] {
return &PATError{RawJSON: cleanPATJSON(body, code)}
}
if code, ok := getPATErrorCode(body); ok {
return &PATError{RawJSON: cleanPATJSON(body, code)}
}
if isBusinessError(body) {
@@ -214,12 +329,42 @@ var patTopLevelStrip = map[string]bool{
"message": true, "error": true, "trace_id": true, "class": true,
}
// ApplyHostMutations writes the two stderr-JSON fields the host integration
// contract requires onto out["data"]:
// - data.hostControl: present iff the CLI is in host-owned mode (i.e.
// HostControlBlock returns non-nil); legacy data.callbacks is stripped
// in the same pass so passive classifier and active retry paths stay
// byte-for-byte aligned.
// - data.openBrowser: always present; reflects the user's PAT browser
// policy.
//
// Centralizing the two writes here is the single-injection invariant —
// any caller that produces a PAT-shaped stderr payload (cleanPATJSON,
// active-retry enrichers, scope-required builders) MUST go through this
// function instead of writing the fields directly. out["data"] is
// promoted to map[string]any if missing or of the wrong type.
func ApplyHostMutations(out map[string]any) {
data, ok := out["data"].(map[string]any)
if !ok || data == nil {
data = map[string]any{}
out["data"] = data
}
if block := HostControlBlock(); block != nil {
delete(data, "callbacks")
data["hostControl"] = block
}
data["openBrowser"] = PATOpenBrowserValue()
}
func cleanPATJSON(body map[string]any, code string) string {
out := map[string]any{
"success": false,
"code": code,
}
if data, ok := body["data"]; ok {
// Keep data.uri exactly as returned by the service. Host consumers open
// that link directly, so local normalization would risk dropping
// parameters embedded in query/hash/fragment sections.
out["data"] = stripClassFields(data)
} else {
fallback := map[string]any{}
@@ -232,7 +377,12 @@ func cleanPATJSON(body map[string]any, code string) string {
out["data"] = stripClassFields(fallback)
}
}
b, err := json.MarshalIndent(out, "", " ")
ApplyHostMutations(out)
// stderr JSON MUST be a single-line, directly json.Unmarshal-able
// payload — pretty-printing would break naïve host parsers that read
// stderr line-by-line and fail on leading whitespace.
b, err := json.Marshal(out)
if err != nil {
return fmt.Sprintf(`{"success":false,"code":"%s"}`, code)
}
@@ -244,15 +394,11 @@ func cleanPATJSON(body map[string]any, code string) string {
// 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
// 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)}
}
}
if code, ok := getPATErrorCode(content); ok {
return &PATError{RawJSON: cleanPATJSON(content, code)}
}
return nil
}
+298 -2
View File
@@ -14,6 +14,7 @@
package errors
import (
"encoding/json"
stderrors "errors"
"strings"
"testing"
@@ -143,8 +144,17 @@ func TestIsNotLoggedInError_False(t *testing.T) {
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")
if !isNotLoggedInError(body) {
t.Fatal("expected true when equivalent auth message is present in message")
}
}
func TestGetDWSGatewayErrorCode_CodeField(t *testing.T) {
t.Parallel()
body := map[string]any{"code": "DWS_SERVICE_UNAUTHORIZED"}
code, ok := getDWSGatewayErrorCode(body)
if !ok || code != "DWS_SERVICE_UNAUTHORIZED" {
t.Fatalf("getDWSGatewayErrorCode() = (%q, %t), want DWS_SERVICE_UNAUTHORIZED, true", code, ok)
}
}
@@ -234,6 +244,44 @@ func TestClassifyToolResultContent_PATPermission(t *testing.T) {
}
}
func TestClassifyToolResultContent_PATPermissionLegacyErrorCode(t *testing.T) {
t.Parallel()
content := map[string]any{
"error_code": "PAT_LOW_RISK_NO_PERMISSION",
"data": map[string]any{"desc": "需要授权"},
}
err := ClassifyToolResultContent(content)
if err == nil {
t.Fatal("expected non-nil error for legacy error_code PAT permission")
}
var patErr *PATError
if !stderrors.As(err, &patErr) {
t.Fatalf("expected *PATError, got %T", err)
}
if !strings.Contains(patErr.RawJSON, "PAT_LOW_RISK_NO_PERMISSION") {
t.Errorf("RawJSON should contain PAT_LOW_RISK_NO_PERMISSION, got: %s", patErr.RawJSON)
}
}
func TestClassifyToolResultContent_PATAuthRequired(t *testing.T) {
t.Parallel()
content := map[string]any{
"errorCode": "AGENT_CODE_NOT_EXISTS",
"data": map[string]any{"agentCode": "agt-missing"},
}
err := ClassifyToolResultContent(content)
if err == nil {
t.Fatal("expected non-nil error for PAT auth-required selector")
}
var patErr *PATError
if !stderrors.As(err, &patErr) {
t.Fatalf("expected *PATError, got %T", err)
}
if !strings.Contains(patErr.RawJSON, "AGENT_CODE_NOT_EXISTS") {
t.Errorf("RawJSON should contain AGENT_CODE_NOT_EXISTS, got: %s", patErr.RawJSON)
}
}
func TestClassifyToolResultContent_NoError(t *testing.T) {
t.Parallel()
content := map[string]any{"success": true, "data": "ok"}
@@ -294,6 +342,41 @@ func TestClassifyMCPResponseText_PATPermission(t *testing.T) {
}
}
func TestClassifyMCPResponseText_PATPermissionLegacyErrorCode(t *testing.T) {
t.Parallel()
text := `{"error_code":"PAT_MEDIUM_RISK_NO_PERMISSION","data":{"desc":"legacy"}}`
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_MEDIUM_RISK_NO_PERMISSION") {
t.Errorf("RawJSON should contain legacy code, got: %s", patErr.RawJSON)
}
}
func TestClassifyMCPResponseText_PATAuthRequired(t *testing.T) {
t.Parallel()
text := `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED","data":{"missingScope":"mail:send"}}`
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_SCOPE_AUTH_REQUIRED") {
t.Errorf("RawJSON should contain PAT_SCOPE_AUTH_REQUIRED, got: %s", patErr.RawJSON)
}
if !strings.Contains(patErr.RawJSON, "missingScope") {
t.Errorf("RawJSON should preserve missingScope, got: %s", patErr.RawJSON)
}
}
func TestClassifyMCPResponseText_BusinessError(t *testing.T) {
t.Parallel()
text := `{"success":false,"errorMsg":"搜索内容不能为空"}`
@@ -345,6 +428,18 @@ func TestClassifyPatAuthCheck_PATNoPermission(t *testing.T) {
}
}
func TestClassifyPatAuthCheck_LegacyErrorCode(t *testing.T) {
t.Parallel()
content := map[string]any{"error_code": "PAT_HIGH_RISK_NO_PERMISSION", "data": map[string]any{"flowId": "f1"}}
patErr := ClassifyPatAuthCheck(content)
if patErr == nil {
t.Fatal("expected non-nil *PATError for legacy error_code")
}
if !strings.Contains(patErr.RawJSON, "PAT_HIGH_RISK_NO_PERMISSION") {
t.Errorf("RawJSON should contain PAT_HIGH_RISK_NO_PERMISSION, 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"}}
@@ -357,6 +452,43 @@ func TestClassifyPatAuthCheck_AgentCodeNotExists(t *testing.T) {
}
}
// TestClassifyPatAuthCheck_scope_auth_required pins the PAT_SCOPE_AUTH_REQUIRED
// selector (part of the frozen PAT-family enum; see patAuthRequiredCodes in
// internal/errors/pat.go) as a PATError with exit=4 so hosts can kick the
// `dws auth login --scope <data.missingScope>` branch.
func TestClassifyPatAuthCheck_scope_auth_required(t *testing.T) {
t.Parallel()
content := map[string]any{
"success": false,
"code": "PAT_SCOPE_AUTH_REQUIRED",
"data": map[string]any{"missingScope": "mail:send"},
}
patErr := ClassifyPatAuthCheck(content)
if patErr == nil {
t.Fatal("expected non-nil *PATError for PAT_SCOPE_AUTH_REQUIRED")
}
// Error value MUST satisfy the ExitCoder contract (exit=4) so the
// process exits with the PAT Frozen code regardless of wrapping.
var ec interface{ ExitCode() int } = patErr
if ec.ExitCode() != ExitCodePermission {
t.Errorf("ExitCode() = %d, want %d", ec.ExitCode(), ExitCodePermission)
}
// Host-visible RawStderr must carry the selector and, crucially, the
// missingScope field that drives `dws auth login --scope <x>`.
raw := patErr.RawStderr()
if !strings.Contains(raw, "PAT_SCOPE_AUTH_REQUIRED") {
t.Errorf("RawStderr missing selector, got: %s", raw)
}
if !strings.Contains(raw, "missingScope") {
t.Errorf("RawStderr missing missingScope field, got: %s", raw)
}
if !strings.Contains(raw, "mail:send") {
t.Errorf("RawStderr missing missingScope value, got: %s", raw)
}
}
func TestClassifyPatAuthCheck_NoMatch(t *testing.T) {
t.Parallel()
content := map[string]any{"code": "SOME_BUSINESS_ERROR", "message": "oops"}
@@ -423,6 +555,9 @@ func TestCleanPATJSON_WithData(t *testing.T) {
if strings.Contains(result, "class") {
t.Errorf("expected class field to be stripped, got: %s", result)
}
if !strings.Contains(result, `"openBrowser":true`) {
t.Errorf("expected openBrowser default in output, got: %s", result)
}
}
func TestCleanPATJSON_WithoutData(t *testing.T) {
@@ -443,6 +578,167 @@ func TestCleanPATJSON_WithoutData(t *testing.T) {
}
}
// TestCleanPATJSON_InjectsHostControlWhenClawSet verifies the
// single-injection invariant: when the bootstrap wires a non-empty
// clawType provider, cleanPATJSON MUST emit data.hostControl.
func TestCleanPATJSON_InjectsHostControlWhenClawSet(t *testing.T) {
// Not parallel: mutates the package-level provider.
t.Cleanup(func() { SetHostControlProvider(nil) })
SetHostControlProvider(func() string { return "my-copilot" })
body := map[string]any{
"success": false,
"code": "PAT_NO_PERMISSION",
"data": map[string]any{
"desc": "需要授权",
"flowId": "f-1",
"callbacks": []any{"cb1", "cb2"},
},
}
raw := cleanPATJSON(body, "PAT_NO_PERMISSION")
var parsed map[string]any
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, raw)
}
data, ok := parsed["data"].(map[string]any)
if !ok {
t.Fatalf("expected data object, got %T", parsed["data"])
}
hc, ok := data["hostControl"].(map[string]any)
if !ok {
t.Fatalf("expected data.hostControl to be a map, got %T\nraw=%s", data["hostControl"], raw)
}
if got, _ := hc["clawType"].(string); got != "my-copilot" {
t.Errorf("hostControl.clawType = %q, want %q", got, "my-copilot")
}
if got, _ := hc["callbackOwner"].(string); got != "host" {
t.Errorf("hostControl.callbackOwner = %q, want %q", got, "host")
}
if got, _ := hc["mode"].(string); got != "host" {
t.Errorf("hostControl.mode = %q, want %q", got, "host")
}
if _, ok := data["callbacks"]; ok {
t.Fatalf("cleanPATJSON should strip callbacks in host-owned mode, got: %v", data["callbacks"])
}
}
// TestCleanPATJSON_OmitsHostControlByDefault verifies that cleanPATJSON
// does NOT include a hostControl block when the provider is unset or
// returns empty (default CLI-owned mode).
func TestCleanPATJSON_OmitsHostControlByDefault(t *testing.T) {
// Not parallel: reads the package-level provider.
t.Cleanup(func() { SetHostControlProvider(nil) })
SetHostControlProvider(nil)
body := map[string]any{
"success": false,
"code": "PAT_NO_PERMISSION",
"data": map[string]any{
"desc": "need auth",
},
}
raw := cleanPATJSON(body, "PAT_NO_PERMISSION")
if strings.Contains(raw, `"hostControl"`) {
t.Fatalf("cleanPATJSON should omit hostControl in default mode, got: %s", raw)
}
SetHostControlProvider(func() string { return "" })
raw = cleanPATJSON(body, "PAT_NO_PERMISSION")
if strings.Contains(raw, `"hostControl"`) {
t.Fatalf("cleanPATJSON should omit hostControl when provider returns empty, got: %s", raw)
}
}
func TestCleanPATJSON_UsesBrowserPolicyProvider(t *testing.T) {
t.Cleanup(func() {
SetHostControlProvider(nil)
SetPATOpenBrowserProvider(nil)
})
SetHostControlProvider(nil)
SetPATOpenBrowserProvider(func() bool { return false })
body := map[string]any{
"success": false,
"code": "PAT_NO_PERMISSION",
"data": map[string]any{
"desc": "need auth",
},
}
raw := cleanPATJSON(body, "PAT_NO_PERMISSION")
var parsed map[string]any
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, raw)
}
data, _ := parsed["data"].(map[string]any)
if got, ok := data["openBrowser"].(bool); !ok || got {
t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"])
}
}
// TestCleanPATJSON_SingleLineOutput pins down the wire invariant: stderr
// JSON MUST be emitted as a single line (no embedded \n, no pretty-print
// indentation) so that naïve host parsers reading stderr line-by-line stay
// correct. Regression guard against accidental reintroduction of
// json.MarshalIndent.
func TestCleanPATJSON_SingleLineOutput(t *testing.T) {
t.Parallel()
body := map[string]any{
"success": false,
"code": "PAT_LOW_RISK_NO_PERMISSION",
"data": map[string]any{
"requiredScopes": []any{"aitable.record:read"},
"grantOptions": []any{"session", "permanent"},
"displayName": "读取记录",
"productName": "AI 表格",
},
}
raw := cleanPATJSON(body, "PAT_LOW_RISK_NO_PERMISSION")
if strings.Contains(raw, "\n") {
t.Fatalf("cleanPATJSON output must be single-line, got embedded newline:\n%s", raw)
}
if strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t") {
t.Fatalf("cleanPATJSON output must not be indented, got leading whitespace: %q", raw)
}
// Contract: the payload must remain a directly json.Unmarshal-able
// object, even after the single-line constraint is enforced.
var parsed map[string]any
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
t.Fatalf("single-line output must round-trip via json.Unmarshal: %v\nraw=%s", err, raw)
}
if code, _ := parsed["code"].(string); code != "PAT_LOW_RISK_NO_PERMISSION" {
t.Errorf("code = %q, want %q", code, "PAT_LOW_RISK_NO_PERMISSION")
}
}
func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) {
t.Parallel()
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D50dff7654b7444e88ced7489b07cce8d%26userCode%3DQ8RY-X6E9#/personalAuthorization?flowId=50dff7654b7444e88ced7489b07cce8d&userCode=Q8RY-X6E9"
body := map[string]any{
"success": false,
"code": "PAT_MEDIUM_RISK_NO_PERMISSION",
"data": map[string]any{
"desc": "在浏览器中打开以下链接进行认证",
"flowId": "50dff7654b7444e88ced7489b07cce8d",
"uri": rawURI,
},
}
result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION")
var parsed map[string]any
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result)
}
data, _ := parsed["data"].(map[string]any)
if got, _ := data["uri"].(string); got != rawURI {
t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI)
}
}
// ---------------------------------------------------------------------------
// stripClassFields
// ---------------------------------------------------------------------------
+17 -4
View File
@@ -268,15 +268,20 @@ func newAttendanceShiftListCommand(runner executor.Runner) *cobra.Command {
func newAttendanceSummaryCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "summary",
Short: "查询某个人的考勤统计摘要",
Long: "查询某个人的考勤统计摘要。--user 与 --date 均必填。",
Example: ` dws attendance summary --user USER_ID --date "2026-03-12 15:00:00"`,
Use: "summary",
Short: "查询某个人的考勤统计摘要",
Long: `查询某个人的考勤统计摘要。
--user、--date、--stats-type 均必填。
钉钉服务端业务层强制要求 --stats-type(week/month),不填会返回 C0002 统计类型错误。`,
Example: ` dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type month
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type week`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
userID, _ := cmd.Flags().GetString("user")
workDateStr, _ := cmd.Flags().GetString("date")
statsType, _ := cmd.Flags().GetString("stats-type")
if userID == "" {
return apperrors.NewValidation("--user is required, provide DingTalk user ID")
}
@@ -287,10 +292,17 @@ func newAttendanceSummaryCommand(runner executor.Runner) *cobra.Command {
if err != nil {
return apperrors.NewValidation("--date format error, use yyyy-MM-dd HH:mm:ss")
}
if statsType == "" {
return apperrors.NewValidation(`--stats-type is required (week|month), enforced by DingTalk server`)
}
if statsType != "week" && statsType != "month" {
return apperrors.NewValidation(`--stats-type must be "week" or "month"`)
}
// Build nested structure QueryUserAttendVO
vo := map[string]any{
"userId": userID,
"queryDate": workDateStr,
"statsType": statsType,
}
params := map[string]any{
"QueryUserAttendVO": vo,
@@ -311,6 +323,7 @@ func newAttendanceSummaryCommand(runner executor.Runner) *cobra.Command {
}
cmd.Flags().String("user", "", "钉钉用户 ID(必填)")
cmd.Flags().String("date", "", "工作日期,格式 yyyy-MM-dd HH:mm:ss,如 2026-03-12 15:00:00(必填)")
cmd.Flags().String("stats-type", "", "统计类型:week(周统计)或 month(月统计)(必填,钉钉服务端业务层强制要求)")
preferLegacyLeaf(cmd)
return cmd
}
+67 -27
View File
@@ -92,11 +92,14 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
--open-dingtalk-id 指定 openDingTalkId 发单聊 (适用于无法获取 userId 的场景)。
三者只能选其一,不能同时指定。
消息内容通过 --text 传入,也可作为位置参数;支持 Markdown。可选 --title 作为消息标题。`,
消息内容通过 --text 传入,也可作为位置参数;支持 Markdown。必须提供 --title 作为消息标题。
群聊场景下可用 --at-all / --at-users / --at-mobiles 进行 @ 提醒(仅 --group 时生效)。
注意 --text 中需包含对应的 <@userId> / <@all> 占位符才能在客户端渲染出 @ 效果。`,
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> --title "提醒" --text "请确认"
dws chat message send --group <openconversation_id> "hello"`,
dws chat message send --group <openconversation_id> --title "拉群通知" --text "<@uid> 你被 @ 了" --at-users uid`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -125,6 +128,9 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("open-dingtalk-id", "", "接收人 openDingTalkId (单聊三选一)")
cmd.Flags().String("text", "", "消息内容,支持 Markdown (也可作位置参数)")
cmd.Flags().String("title", "", "消息标题 (可选)")
cmd.Flags().Bool("at-all", false, "@所有人 (仅 --group 群聊生效)")
cmd.Flags().String("at-users", "", "按 userId @ 指定成员,逗号分隔 (仅 --group 群聊生效)")
cmd.Flags().String("at-mobiles", "", "按手机号 @ 指定成员,逗号分隔 (仅 --group 群聊生效)")
return cmd
}
@@ -181,6 +187,15 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
return nil, "", apperrors.NewValidation("--text (or positional argument) is required")
}
atAll, _ := cmd.Flags().GetBool("at-all")
atUsers, _ := cmd.Flags().GetString("at-users")
atMobiles, _ := cmd.Flags().GetString("at-mobiles")
hasAtUsers := strings.TrimSpace(atUsers) != ""
hasAtMobiles := strings.TrimSpace(atMobiles) != ""
if !hasGroup && (atAll || hasAtUsers || hasAtMobiles) {
return nil, "", apperrors.NewValidation("--at-all / --at-users / --at-mobiles only apply when --group is set")
}
params := map[string]any{"text": text}
if strings.TrimSpace(title) != "" {
params["title"] = title
@@ -189,6 +204,15 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
switch {
case hasGroup:
params["openConversation_id"] = group
if atAll {
params["isAtAll"] = true
}
if hasAtUsers {
params["atUserIds"] = splitCSV(atUsers)
}
if hasAtMobiles {
params["atMobiles"] = splitCSV(atMobiles)
}
return params, "send_message_as_user", nil
case hasUser:
params["receiverUserId"] = user
@@ -228,9 +252,9 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
preferLegacyLeaf(cmd)
cmd.Flags().String("group", "", "群会话 openConversationId (群聊必填)")
cmd.Flags().String("robot-code", "", "机器人 Code")
cmd.Flags().String("text", "", "消息内容 (Markdown)")
cmd.Flags().String("title", "", "消息标题")
cmd.Flags().String("robot-code", "", "机器人 Code (必填)")
cmd.Flags().String("text", "", "消息内容 Markdown (必填)")
cmd.Flags().String("title", "", "消息标题 (必填)")
cmd.Flags().String("users", "", "接收者 userId 列表,逗号分隔,最多 20 个 (单聊必填)")
return cmd
}
@@ -298,13 +322,18 @@ func newChatGroupCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: newChatGroupMembersListRunE(runner),
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
members.Flags().String("id", "", "群 ID / openconversation_id (必填)")
members.Flags().String("cursor", "", "分页游标")
// Keeps the helper-restructured group winning over the dynamic envelope's
// `members` leaf (which only exposes `get_group_members`); without this
// the merge layer treats the shape mismatch as "envelope is authority"
// and drops the entire helper subtree (issue #164).
preferLegacyLeaf(members)
members.AddCommand(
newChatGroupMembersListCommand(runner),
newChatGroupMemberAddCommand(runner),
newChatGroupMemberRemoveCommand(runner),
newChatGroupMembersAddBotCommand(runner),
@@ -675,26 +704,37 @@ func newChatMessageSendByWebhookCommand(runner executor.Runner) *cobra.Command {
// ── group members list ─────────────────────────────────────
func newChatGroupMembersListRunE(runner executor.Runner) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
groupID, _ := cmd.Flags().GetString("id")
if strings.TrimSpace(groupID) == "" {
return apperrors.NewValidation("--id is required")
}
params := map[string]any{
"openconversation_id": groupID,
}
if v, _ := cmd.Flags().GetString("cursor"); v != "" {
params["cursor"] = v
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "chat", "get_group_members", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
func newChatGroupMembersListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "查询群成员列表",
Example: ` dws chat group members list --id <openconversation_id>`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
groupID, _ := cmd.Flags().GetString("id")
if strings.TrimSpace(groupID) == "" {
return apperrors.NewValidation("--id is required")
}
params := map[string]any{
"openconversation_id": groupID,
}
if v, _ := cmd.Flags().GetString("cursor"); v != "" {
params["cursor"] = v
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "chat", "get_group_members", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
cmd.Flags().String("cursor", "", "分页游标 (首页留空)")
return cmd
}
// ── group rename ───────────────────────────────────────────
+211
View File
@@ -7,6 +7,7 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
type captureRunner struct {
@@ -151,6 +152,216 @@ func TestChatMessageSendRejectsInvalidDestination(t *testing.T) {
}
}
// TestChatMessageSendForwardsAtMentions guards the regression introduced
// alongside the destination-based routing in PR #170: the hardcoded helper
// declared --group / --user / --open-dingtalk-id / --text / --title but
// dropped the v1.0.15 envelope's --at-users / --at-all / --at-mobiles flags,
// so `dws chat message send --group ... --at-users ...` failed with
// `unknown flag: --at-users` (issue #177).
func TestChatMessageSendForwardsAtMentions(t *testing.T) {
cases := []struct {
name string
args []string
wantParams map[string]any
}{
{
name: "group-with-at-users",
args: []string{
"--group", "cid-xyz",
"--title", "拉群通知",
"--text", "<@uid-1> <@uid-2> 请关注",
"--at-users", "uid-1,uid-2",
},
wantParams: map[string]any{
"openConversation_id": "cid-xyz",
"title": "拉群通知",
"text": "<@uid-1> <@uid-2> 请关注",
"atUserIds": []any{"uid-1", "uid-2"},
},
},
{
name: "group-with-at-all",
args: []string{
"--group", "cid-xyz",
"--title", "全员通知",
"--text", "<@all> 请关注",
"--at-all",
},
wantParams: map[string]any{
"openConversation_id": "cid-xyz",
"title": "全员通知",
"text": "<@all> 请关注",
"isAtAll": true,
},
},
{
name: "group-with-at-mobiles",
args: []string{
"--group", "cid-xyz",
"--title", "提醒",
"--text", "请 13800000000 确认",
"--at-mobiles", "13800000000,13900000000",
},
wantParams: map[string]any{
"openConversation_id": "cid-xyz",
"title": "提醒",
"text": "请 13800000000 确认",
"atMobiles": []any{"13800000000", "13900000000"},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
runner := &captureRunner{}
cmd := newChatMessageSendCommand(runner)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs(tc.args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if got := runner.last.Tool; got != "send_message_as_user" {
t.Fatalf("Tool = %q, want send_message_as_user", got)
}
for key, want := range tc.wantParams {
got, ok := runner.last.Params[key]
if !ok {
t.Fatalf("Params missing %q; got %#v", key, runner.last.Params)
}
if !equalAny(got, want) {
t.Fatalf("Params[%q] = %#v, want %#v", key, got, want)
}
}
})
}
}
// TestChatMessageSendRejectsAtMentionsOutsideGroup ensures we do not silently
// drop user intent when --at-* is combined with --user / --open-dingtalk-id
// (single-chat tools have no @-mention semantics, so the flag would never
// take effect — fail loudly instead of swallowing).
func TestChatMessageSendRejectsAtMentionsOutsideGroup(t *testing.T) {
cases := []struct {
name string
args []string
}{
{
name: "user-with-at-users",
args: []string{"--user", "034766", "--text", "hi", "--at-users", "uid-1"},
},
{
name: "open-dingtalk-id-with-at-all",
args: []string{"--open-dingtalk-id", "OP123", "--text", "hi", "--at-all"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
runner := &captureRunner{}
cmd := newChatMessageSendCommand(runner)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs(tc.args)
err := cmd.Execute()
if err == nil {
t.Fatalf("expected error, got nil; output: %s", out.String())
}
if !strings.Contains(err.Error(), "only apply when --group is set") {
t.Fatalf("error = %q, want '...only apply when --group is set'", err.Error())
}
})
}
}
func equalAny(a, b any) bool {
switch av := a.(type) {
case []any:
bv, ok := b.([]any)
if !ok || len(av) != len(bv) {
return false
}
for i := range av {
if av[i] != bv[i] {
return false
}
}
return true
default:
return a == b
}
}
// TestChatGroupMembersListSubcommand pins the explicit `list` subcommand
// added for issue #164: previously the bare `chat group members --id` was
// the list path, but it shape-mismatched the dynamic envelope's `members`
// leaf and got eaten by the merge layer. Now `dws chat group members list
// --id <cid>` is a proper leaf siblings of add/remove/add-bot.
func TestChatGroupMembersListSubcommand(t *testing.T) {
runner := &captureRunner{}
groupCmd := newChatGroupCommand(runner)
var members *cobra.Command
for _, sub := range groupCmd.Commands() {
if sub.Name() == "members" {
members = sub
break
}
}
if members == nil {
t.Fatalf("members subcommand missing under chat group")
}
want := map[string]bool{"list": false, "add": false, "remove": false, "add-bot": false}
for _, leaf := range members.Commands() {
if _, ok := want[leaf.Name()]; ok {
want[leaf.Name()] = true
}
}
for name, seen := range want {
if !seen {
t.Errorf("expected `chat group members %s` subcommand, missing", name)
}
}
if members.Flags().Lookup("id") != nil {
t.Errorf("members container should not declare --id (moved to `list` subcommand to avoid shape-mismatch with dynamic envelope)")
}
var listCmd *cobra.Command
for _, leaf := range members.Commands() {
if leaf.Name() == "list" {
listCmd = leaf
break
}
}
if listCmd == nil {
t.Fatalf("`list` subcommand not found")
}
if listCmd.Flags().Lookup("id") == nil {
t.Errorf("`list` subcommand must declare --id")
}
if listCmd.Flags().Lookup("cursor") == nil {
t.Errorf("`list` subcommand must declare --cursor")
}
// Drive execution via the group root so cobra resolves the subcommand
// path properly (calling Execute() on a child directly would re-enter
// the root help branch).
var out bytes.Buffer
groupCmd.SetOut(&out)
groupCmd.SetErr(&out)
groupCmd.SetArgs([]string{"members", "list", "--id", "cid-xyz"})
if err := groupCmd.Execute(); err != nil {
t.Fatalf("members list Execute error = %v\noutput: %s", err, out.String())
}
if got := runner.last.Tool; got != "get_group_members" {
t.Fatalf("Tool = %q, want get_group_members", got)
}
if got := runner.last.Params["openconversation_id"]; got != "cid-xyz" {
t.Fatalf("openconversation_id = %#v, want cid-xyz", got)
}
}
func TestChatMessageSendByBotRoutesToBotProduct(t *testing.T) {
cases := []struct {
name string
+13 -3
View File
@@ -204,8 +204,18 @@ func newReportCreateCommand(runner executor.Runner) *cobra.Command {
Use: "create",
Short: "创建日志",
Long: `按模版创建一条日志。--contents 为 JSON 数组,每项需含 key、sort、content、contentType、type,
与远程 create_report 一致;可先通过 report template list / template detail 取得 templateId 与控件定义。`,
Example: ` dws report create --template-id TPL_ID --contents '[{"content":"完成开发","sort":"0","key":"今日完成","contentType":"markdown","type":"1"}]'
与远程 create_report 一致;可先通过 report template list / template detail 取得 templateId 与控件定义。
注意:每个 contents 项的 key 必须精确等于模板的 field_name(中文/英文逐字匹配,不是控件 ID 或别名)。
key 与 field_name 不一致时钉钉 API 会返回 SYSTEM_ERROR (success=false),CLI 层不会预先拦截。
请先用 report template detail 查到 report_template_fields[].field_name 后再填。`,
Example: ` # Step 1:查模板,取 report_template_fields[].field_name 当作 contents[].key
dws report template detail --name "周报"
# Step 2:用上一步拿到的 field_name 作为 key 创建日志
dws report create --template-id TPL_ID --contents '[{"key":"<field_name>","sort":"0","content":"完成开发","contentType":"markdown","type":"1"}]'
# 同时通知到接收人单聊
dws report create --template-id TPL_ID --contents '[...]' --to-chat --to-user-ids userId1,userId2`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
@@ -251,7 +261,7 @@ func newReportCreateCommand(runner executor.Runner) *cobra.Command {
},
}
cmd.Flags().String("template-id", "", "日志模版 ID (必填)")
cmd.Flags().String("contents", "", "日志内容 JSON 数组 (必填),每项含 key/sort/content/contentType/type")
cmd.Flags().String("contents", "", "日志内容 JSON 数组 (必填),每项含 key/sort/content/contentType/type;key 必须精确等于模板 field_name (用 report template detail --name <模板名> 查询)")
cmd.Flags().String("dd-from", "dws", "创建来源标识")
cmd.Flags().Bool("to-chat", false, "是否发送到日志接收人单聊")
cmd.Flags().String("to-user-ids", "", "接收人 userId,逗号分隔 (可选)")
+34 -5
View File
@@ -83,7 +83,7 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
Example: ` dws todo task create --title "修复线上Bug" --executors userId1,userId2 --priority 40
dws todo task create --title "提交报告" --executors userId1 --due "2026-03-10T18:00:00+08:00"
# 查询 userId: dws contact user search --keyword "姓名"`,
# 查询 userId: dws contact user search --query "姓名"`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -135,7 +135,7 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
preferLegacyLeaf(cmd)
cmd.Flags().String("title", "", i18n.T("待办标题 (必填)"))
cmd.Flags().String("executors", "", i18n.T("执行者 userId 列表 (必填)"))
cmd.Flags().String("executors", "", i18n.T("执行者 userId 列表,逗号分隔 (必填)。注意: 此处是通讯录 userId,可通过 dws contact user search --query 姓名 查询"))
cmd.Flags().String("due", "", i18n.T("截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)"))
cmd.Flags().String("priority", "", i18n.T("优先级: 10低/20普通/30较高/40紧急"))
cmd.Flags().String("recurrence", "", i18n.T("循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1"))
@@ -152,8 +152,20 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
func newTodoTaskListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: i18n.T("查询待办列表"),
Use: "list",
Short: i18n.T("查询待办列表"),
Long: i18n.T(`查询当前用户在当前企业的待办列表。
覆盖范围:
返回当前用户作为"执行者"(executor) 的待办。
仅参与但不执行的待办、自己创建但交给他人执行的待办不在返回范围内。
当前列表能力面向"个人待办",即钉钉待办模块中展示的待办任务,
不包含 OA 审批流待办、Teambition 项目任务等其他业务线的待办。
分页:
默认每页 20 条。--size 超过 20 时,CLI 会自动进行多次 API 调用
并合并结果(自动分页),无需手动翻页。`),
Example: ` dws todo task list --page 1 --size 20 --status false`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
@@ -362,6 +374,23 @@ func newTodoTaskGetCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "get",
Short: i18n.T("待办详情"),
Long: i18n.T(`查看待办任务详情。
返回字段说明:
creatorId / executorIds / participantIds / modifierId
待办系统内部人员标识(短数字 ID,如 6380165826),
不是通讯录 userId(如 035551044606950179)或 unionid。
这些 ID 在待办系统内对同一用户稳定,但无法直接用于通讯录 API 查询。
如需获取人员姓名,可参考返回中的 creatorInfo / executorInfos /
participantInfos 字段(包含 name 属性)。
bizTag / source
底层待办引擎的实现标识。即使是在钉钉客户端直接创建的普通个人待办,
也会返回 "teambition",这是内核实现细节,不代表来自 Teambition 产品。
tenantId / tenantType
待办所属的租户标识,非企业 corpId。tenantType 为 "user" 时
tenantId 是用户维度标识;为 "org" 时是组织维度标识。`),
Example: ` dws todo task get --task-id <taskId>
# 查询 taskId: dws todo task list`,
@@ -379,7 +408,7 @@ func newTodoTaskGetCommand(runner executor.Runner) *cobra.Command {
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
"todo",
"query_todo_detail",
"get_todo_detail",
params,
)
invocation.DryRun = commandDryRun(cmd)
+228
View File
@@ -0,0 +1,228 @@
// 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 (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
const browserPolicyFile = "pat_policy.json"
type browserPolicyValue struct {
OpenBrowser bool `json:"openBrowser"`
}
type BrowserPolicy struct {
Default *browserPolicyValue `json:"default,omitempty"`
Agents map[string]browserPolicyValue `json:"agents,omitempty"`
}
type BrowserPolicySelection struct {
Scope string `json:"scope"`
AgentCode string `json:"agentCode,omitempty"`
OpenBrowser bool `json:"openBrowser"`
Source string `json:"source"`
}
func patPolicyPath(configDir string) string {
return filepath.Join(configDir, browserPolicyFile)
}
func patConfigDir() string {
if envDir := os.Getenv("DWS_CONFIG_DIR"); envDir != "" {
return envDir
}
if fn := edition.Get().ConfigDir; fn != nil {
return fn()
}
homeDir, err := os.UserHomeDir()
if err != nil {
return ".dws"
}
return filepath.Join(homeDir, ".dws")
}
func LoadBrowserPolicy(configDir string) (*BrowserPolicy, error) {
path := patPolicyPath(configDir)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &BrowserPolicy{}, nil
}
return nil, fmt.Errorf("reading PAT browser policy: %w", err)
}
var policy BrowserPolicy
if err := json.Unmarshal(data, &policy); err != nil {
return nil, fmt.Errorf("parsing PAT browser policy: %w", err)
}
if policy.Agents == nil {
policy.Agents = map[string]browserPolicyValue{}
}
return &policy, nil
}
func saveBrowserPolicy(configDir string, policy *BrowserPolicy) error {
if policy == nil {
policy = &BrowserPolicy{}
}
if policy.Agents == nil {
policy.Agents = map[string]browserPolicyValue{}
}
data, err := json.MarshalIndent(policy, "", " ")
if err != nil {
return fmt.Errorf("marshaling PAT browser policy: %w", err)
}
data = append(data, '\n')
if err := helpers.AtomicWriteJSON(patPolicyPath(configDir), data); err != nil {
return fmt.Errorf("writing PAT browser policy: %w", err)
}
return nil
}
func ResolveBrowserPolicy(configDir, explicitAgentCode string) (BrowserPolicySelection, error) {
agentCode, err := resolveAgentCode(explicitAgentCode, false)
if err != nil {
return BrowserPolicySelection{}, err
}
policy, err := LoadBrowserPolicy(configDir)
if err != nil {
return BrowserPolicySelection{}, err
}
if agentCode != "" {
if entry, ok := policy.Agents[agentCode]; ok {
return BrowserPolicySelection{
Scope: "agent",
AgentCode: agentCode,
OpenBrowser: entry.OpenBrowser,
Source: "agent",
}, nil
}
}
if policy.Default != nil {
return BrowserPolicySelection{
Scope: "default",
OpenBrowser: policy.Default.OpenBrowser,
Source: "default",
}, nil
}
return BrowserPolicySelection{
Scope: "builtin_default",
AgentCode: agentCode,
OpenBrowser: true,
Source: "builtin_default",
}, nil
}
func EffectiveOpenBrowser(configDir string) bool {
selection, err := ResolveBrowserPolicy(configDir, "")
if err != nil {
return true
}
return selection.OpenBrowser
}
func resolveBrowserPolicyWriteAgentCode(explicitAgentCode string) (string, error) {
agentCode := strings.TrimSpace(explicitAgentCode)
if agentCode == "" {
return "", nil
}
if err := validateAgentCode(agentCode); err != nil {
return "", err
}
return agentCode, nil
}
func SetBrowserPolicy(configDir, explicitAgentCode string, enabled bool) (BrowserPolicySelection, error) {
agentCode, err := resolveBrowserPolicyWriteAgentCode(explicitAgentCode)
if err != nil {
return BrowserPolicySelection{}, err
}
policy, err := LoadBrowserPolicy(configDir)
if err != nil {
return BrowserPolicySelection{}, err
}
if policy.Agents == nil {
policy.Agents = map[string]browserPolicyValue{}
}
if agentCode != "" {
policy.Agents[agentCode] = browserPolicyValue{OpenBrowser: enabled}
if err := saveBrowserPolicy(configDir, policy); err != nil {
return BrowserPolicySelection{}, err
}
return BrowserPolicySelection{
Scope: "agent",
AgentCode: agentCode,
OpenBrowser: enabled,
Source: "agent",
}, nil
}
policy.Default = &browserPolicyValue{OpenBrowser: enabled}
if err := saveBrowserPolicy(configDir, policy); err != nil {
return BrowserPolicySelection{}, err
}
return BrowserPolicySelection{
Scope: "default",
OpenBrowser: enabled,
Source: "default",
}, nil
}
func newBrowserPolicyCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "browser-policy",
Short: "配置 PAT 授权时是否打开浏览器",
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
if !cmd.Flags().Changed("enabled") {
return fmt.Errorf("--enabled is required")
}
enabled, err := cmd.Flags().GetBool("enabled")
if err != nil {
return err
}
agentCode, err := cmd.Flags().GetString("agentCode")
if err != nil {
return err
}
selection, err := SetBrowserPolicy(patConfigDir(), agentCode, enabled)
if err != nil {
return err
}
return output.WriteJSON(cmd.OutOrStdout(), selection)
},
}
cmd.Flags().Bool("enabled", false, "PAT 撞墙时是否允许本地打开浏览器")
cmd.Flags().String("agentCode", "", "Agent 唯一标识(可选;不填则写入全局默认策略,不从 env DINGTALK_DWS_AGENTCODE 回退)")
return cmd
}
+237
View File
@@ -0,0 +1,237 @@
// 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 (
"bytes"
"encoding/json"
"testing"
)
func TestBrowserPolicy_DefaultRoundTrip(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
saved, err := SetBrowserPolicy(configDir, "", false)
if err != nil {
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
}
if saved.Scope != "default" {
t.Fatalf("saved.Scope = %q, want default", saved.Scope)
}
if saved.OpenBrowser {
t.Fatal("saved.OpenBrowser = true, want false")
}
loaded, err := ResolveBrowserPolicy(configDir, "")
if err != nil {
t.Fatalf("ResolveBrowserPolicy(default) error = %v", err)
}
if loaded.Scope != "default" {
t.Fatalf("loaded.Scope = %q, want default", loaded.Scope)
}
if loaded.OpenBrowser {
t.Fatal("loaded.OpenBrowser = true, want false")
}
}
func TestSetBrowserPolicy_EmptyAgentCodeIgnoresEnvAndWritesDefault(t *testing.T) {
t.Setenv(agentCodeEnv, "agt-env")
configDir := t.TempDir()
saved, err := SetBrowserPolicy(configDir, "", false)
if err != nil {
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
}
if saved.Scope != "default" {
t.Fatalf("saved.Scope = %q, want default", saved.Scope)
}
if saved.AgentCode != "" {
t.Fatalf("saved.AgentCode = %q, want empty", saved.AgentCode)
}
policy, err := LoadBrowserPolicy(configDir)
if err != nil {
t.Fatalf("LoadBrowserPolicy error = %v", err)
}
if policy.Default == nil {
t.Fatal("policy.Default is nil, want default policy")
}
if got := len(policy.Agents); got != 0 {
t.Fatalf("len(policy.Agents) = %d, want 0", got)
}
loaded, err := ResolveBrowserPolicy(configDir, "")
if err != nil {
t.Fatalf("ResolveBrowserPolicy(default under env) error = %v", err)
}
if loaded.Scope != "default" {
t.Fatalf("loaded.Scope = %q, want default", loaded.Scope)
}
if loaded.AgentCode != "" {
t.Fatalf("loaded.AgentCode = %q, want empty", loaded.AgentCode)
}
if loaded.OpenBrowser {
t.Fatal("loaded.OpenBrowser = true, want false")
}
}
func TestBrowserPolicy_AgentOverridesDefault(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
if _, err := SetBrowserPolicy(configDir, "", true); err != nil {
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
}
if _, err := SetBrowserPolicy(configDir, "agt-sales", false); err != nil {
t.Fatalf("SetBrowserPolicy(agent) error = %v", err)
}
agentLoaded, err := ResolveBrowserPolicy(configDir, "agt-sales")
if err != nil {
t.Fatalf("ResolveBrowserPolicy(agent) error = %v", err)
}
if agentLoaded.Scope != "agent" {
t.Fatalf("agentLoaded.Scope = %q, want agent", agentLoaded.Scope)
}
if agentLoaded.AgentCode != "agt-sales" {
t.Fatalf("agentLoaded.AgentCode = %q, want agt-sales", agentLoaded.AgentCode)
}
if agentLoaded.OpenBrowser {
t.Fatal("agentLoaded.OpenBrowser = true, want false")
}
defaultLoaded, err := ResolveBrowserPolicy(configDir, "agt-other")
if err != nil {
t.Fatalf("ResolveBrowserPolicy(default fallback) error = %v", err)
}
if defaultLoaded.Scope != "default" {
t.Fatalf("defaultLoaded.Scope = %q, want default", defaultLoaded.Scope)
}
if !defaultLoaded.OpenBrowser {
t.Fatal("defaultLoaded.OpenBrowser = false, want true")
}
}
func TestResolveBrowserPolicy_EnvFallback(t *testing.T) {
t.Setenv(agentCodeEnv, "agt-env")
configDir := t.TempDir()
if _, err := SetBrowserPolicy(configDir, "agt-env", false); err != nil {
t.Fatalf("SetBrowserPolicy(agent) error = %v", err)
}
loaded, err := ResolveBrowserPolicy(configDir, "")
if err != nil {
t.Fatalf("ResolveBrowserPolicy(env fallback) error = %v", err)
}
if loaded.Scope != "agent" {
t.Fatalf("loaded.Scope = %q, want agent", loaded.Scope)
}
if loaded.AgentCode != "agt-env" {
t.Fatalf("loaded.AgentCode = %q, want agt-env", loaded.AgentCode)
}
if loaded.OpenBrowser {
t.Fatal("loaded.OpenBrowser = true, want false")
}
}
func TestResolveBrowserPolicy_FallsBackToOpenSourceDefault(t *testing.T) {
t.Parallel()
loaded, err := ResolveBrowserPolicy(t.TempDir(), "")
if err != nil {
t.Fatalf("ResolveBrowserPolicy(fallback) error = %v", err)
}
if loaded.Scope != "builtin_default" {
t.Fatalf("loaded.Scope = %q, want builtin_default", loaded.Scope)
}
if !loaded.OpenBrowser {
t.Fatal("loaded.OpenBrowser = false, want true")
}
}
func TestBrowserPolicyCommand_WritesAgentPolicy(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
cmd := newBrowserPolicyCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stdout)
cmd.SetArgs([]string{"--enabled=false", "--agentCode", "agt-command"})
if err := cmd.Execute(); err != nil {
t.Fatalf("browser-policy Execute() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal(command output) error = %v\nraw=%s", err, stdout.String())
}
if got, _ := payload["scope"].(string); got != "agent" {
t.Fatalf("scope = %q, want agent", got)
}
if got, _ := payload["agentCode"].(string); got != "agt-command" {
t.Fatalf("agentCode = %q, want agt-command", got)
}
if got, _ := payload["openBrowser"].(bool); got {
t.Fatal("openBrowser = true, want false")
}
loaded, err := ResolveBrowserPolicy(configDir, "agt-command")
if err != nil {
t.Fatalf("ResolveBrowserPolicy(agent) error = %v", err)
}
if loaded.OpenBrowser {
t.Fatal("loaded.OpenBrowser = true, want false")
}
}
func TestBrowserPolicyCommand_NoAgentCodeWritesDefaultEvenWhenEnvSet(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
t.Setenv(agentCodeEnv, "agt-env")
cmd := newBrowserPolicyCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stdout)
cmd.SetArgs([]string{"--enabled=false"})
if err := cmd.Execute(); err != nil {
t.Fatalf("browser-policy Execute() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal(command output) error = %v\nraw=%s", err, stdout.String())
}
if got, _ := payload["scope"].(string); got != "default" {
t.Fatalf("scope = %q, want default", got)
}
if _, ok := payload["agentCode"]; ok {
t.Fatalf("unexpected agentCode in default policy output: %v", payload["agentCode"])
}
policy, err := LoadBrowserPolicy(configDir)
if err != nil {
t.Fatalf("LoadBrowserPolicy error = %v", err)
}
if policy.Default == nil {
t.Fatal("policy.Default is nil, want default policy")
}
if got := len(policy.Agents); got != 0 {
t.Fatalf("len(policy.Agents) = %d, want 0", got)
}
}
+309 -21
View File
@@ -16,8 +16,11 @@ package pat
import (
"context"
"encoding/json"
stderrors "errors"
"fmt"
"os"
"regexp"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
@@ -26,13 +29,132 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// resolveSessionIDFromEnv returns the effective session id from environment
// variables. Resolution order:
// 1. DWS_SESSION_ID (primary, stable env name).
// 2. REWIND_SESSION_ID (compatibility alias; kept only so hosts that
// already inject the legacy trace triple keep working without code
// churn).
//
// When both are set to different non-empty values, DWS_SESSION_ID wins
// silently. We deliberately do NOT log either raw session id value or
// any derived fingerprint: this resolver is invoked by `dws pat chmod`
// session grants, and any stderr / ~/.dws/logs capture of those
// identifiers can land verbatim in attached troubleshooting bundles.
// Hosts that need to detect a mismatch between the two env vars must do
// so on the host side before invoking the CLI.
func resolveSessionIDFromEnv() string {
if dws := os.Getenv("DWS_SESSION_ID"); dws != "" {
return dws
}
return os.Getenv("REWIND_SESSION_ID")
}
// agentCodeEnv is the canonical (and only) environment variable name
// used as a per-shell fallback for the --agentCode flag on `dws pat *`
// commands.
//
// Why: agent hosts typically set their business agent code once when
// spawning a long-lived shell / sub-process; requiring `--agentCode` on
// every command in that shell forces the host to rewrite every argv.
// Exposing DINGTALK_DWS_AGENTCODE lets the host export the code once and
// let the CLI resolve it on every pat subcommand. The flag always wins
// when both are set so scripted one-offs remain deterministic.
//
// Namespace note: DWS_AGENTCODE / DINGTALK_AGENTCODE / REWIND_AGENTCODE
// are explicitly NOT consumed. The legacy DWS_AGENTCODE alias was
// hard-removed once the public integration surface landed on
// DINGTALK_DWS_AGENTCODE; hosts must migrate rather than rely on a
// silent fallback.
const agentCodeEnv = "DINGTALK_DWS_AGENTCODE"
// agentCodePattern is the validation regex for any --agentCode value
// resolved from either the flag or the agent-code env var. It matches
// documented agent-code generation schemes (e.g. md5 digests, uuid-like
// ids, short host-assigned slugs) while rejecting shell metacharacters
// and whitespace that would otherwise flow unescaped into an MCP tool
// argument.
var agentCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
// resolveAgentCodeFromEnv returns the fallback agent code from the
// canonical DINGTALK_DWS_AGENTCODE env var. The second return value
// reports the env name that was consumed (for error attribution); it
// is "" when the env is unset or blank. No legacy aliases are honored.
func resolveAgentCodeFromEnv() (string, string) {
primary := strings.TrimSpace(os.Getenv(agentCodeEnv))
if primary != "" {
return primary, agentCodeEnv
}
return "", ""
}
// validateAgentCode rejects agent codes that would be ambiguous or unsafe
// once spliced into a shell / MCP argv. Allowed character set is
// [A-Za-z0-9_-], length 1..64 — see agentCodePattern above.
func validateAgentCode(code string) error {
if code == "" {
return fmt.Errorf("--agentCode must not be empty")
}
if !agentCodePattern.MatchString(code) {
return fmt.Errorf(
"invalid agentCode %q: must match %s (A-Z, a-z, 0-9, _, -; 1..64 chars)",
code, agentCodePattern.String())
}
return nil
}
// resolveAgentCode implements the canonical two-tier lookup for
// --agentCode:
//
// 1. explicit --agentCode flag value (highest priority; wins over env)
// 2. DINGTALK_DWS_AGENTCODE env var (per-shell primary fallback)
// 3. empty ("") when required=false; typed error when required=true.
//
// Any non-empty resolved value is validated via validateAgentCode, so
// callers never have to re-validate.
func resolveAgentCode(flagVal string, required bool) (string, error) {
code := strings.TrimSpace(flagVal)
envSource := ""
if code == "" {
code, envSource = resolveAgentCodeFromEnv()
}
if code == "" {
if required {
return "", fmt.Errorf(
"flag --agentCode is required (or set env %s)\n hint: dws pat chmod <scope>... --agentCode <id>\n hint: export %s=<id>",
agentCodeEnv, agentCodeEnv)
}
return "", nil
}
if err := validateAgentCode(code); err != nil {
if envSource != "" {
return "", fmt.Errorf("%s env: %w", envSource, err)
}
return "", err
}
return code, nil
}
const (
// patGrantToolName is the English-first wire name for the PAT grant tool.
patGrantToolName = "pat.grant"
// patGrantToolNameLegacyAlias is retained for server builds that still
// expose only the legacy Chinese display name.
patGrantToolNameLegacyAlias = "个人授权"
)
var validGrantTypes = map[string]bool{
"once": true,
"session": true,
"permanent": true,
}
func newChmodCommand(caller edition.ToolCaller) *cobra.Command {
// newChmodCommand builds a fresh `dws pat chmod` cobra.Command wired to
// the supplied ToolCaller. A factory is used (instead of a package-level
// var) so multiple RegisterCommands invocations never share mutable flag /
// RunE state across concurrent tests.
func newChmodCommand(c edition.ToolCaller) *cobra.Command {
chmodCmd := &cobra.Command{
Use: "chmod <scope>...",
Short: "授予指定权限",
@@ -50,9 +172,10 @@ grantType 规则:
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>")
flagVal, _ := cmd.Flags().GetString("agentCode")
agentCode, err := resolveAgentCode(flagVal, true)
if err != nil {
return err
}
scopes := args
grantType, _ := cmd.Flags().GetString("grant-type")
@@ -62,14 +185,14 @@ 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") == "" {
if grantType == "session" && sessionID == "" && resolveSessionIDFromEnv() == "" {
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() {
if c != nil && c.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", "Tool:", patGrantToolName)
fmt.Printf("%-16s%s\n", "AgentCode:", agentCode)
fmt.Printf("%-16s%v\n", "Scope:", scopes)
fmt.Printf("%-16s%s\n", "GrantType:", grantType)
@@ -79,24 +202,35 @@ grantType 规则:
return nil
}
if caller == nil {
if c == nil {
return fmt.Errorf("internal error: tool runtime not initialized")
}
if sessionID == "" {
sessionID = resolveSessionIDFromEnv()
}
toolArgs := map[string]any{
"agentCode": agentCode,
"scope": scopes,
"scopes": scopes,
"grantType": grantType,
}
if sessionID == "" {
sessionID = os.Getenv("DWS_SESSION_ID")
}
if sessionID != "" {
toolArgs["sessionId"] = sessionID
}
// Legacy server schema accepted singular "scope"; clone the
// canonical argv and rename the key so the two payloads stay
// in lock-step on every other field.
legacyToolArgs := make(map[string]any, len(toolArgs))
for k, v := range toolArgs {
if k == "scopes" {
legacyToolArgs["scope"] = v
continue
}
legacyToolArgs[k] = v
}
ctx := context.Background()
result, err := caller.CallTool(ctx, "pat", "个人授权", toolArgs)
result, err := callPATToolWithLegacyFallback(ctx, c, "pat", patGrantToolName, patGrantToolNameLegacyAlias, toolArgs, legacyToolArgs)
if err != nil {
return fmt.Errorf("pat chmod failed: %w", err)
}
@@ -105,14 +239,172 @@ grantType 规则:
},
}
chmodCmd.Flags().String("agentCode", "", "Agent 唯一标识(必填)")
_ = chmodCmd.MarkFlagRequired("agentCode")
// --agentCode is required, but we deliberately do NOT call
// MarkFlagRequired here. The agent code may also come from the
// DINGTALK_DWS_AGENTCODE env var; cobra's MarkFlagRequired would
// refuse to run before our resolver has a chance to consume the env.
chmodCmd.Flags().String("agentCode", "",
"Agent 唯一标识(必填;亦可通过 env DINGTALK_DWS_AGENTCODE 注入,flag 优先)")
chmodCmd.Flags().String("grant-type", "session", "授权策略: once|session|permanent")
chmodCmd.Flags().String("session-id", "", "会话标识(session 模式下必填)")
return chmodCmd
}
// callPATToolWithLegacyFallback invokes the canonical PAT grant tool first,
// then silently retries the legacy Chinese alias when the server has not
// registered the canonical tool yet. The retry intentionally emits no stderr
// banner because host-owned PAT callers parse stderr as machine JSON.
func callPATToolWithLegacyFallback(ctx context.Context, c edition.ToolCaller, productID, toolName, legacyAlias string, toolArgs, legacyArgs map[string]any) (*edition.ToolResult, error) {
if c == nil {
return nil, fmt.Errorf("internal error: tool runtime not initialized")
}
result, err := c.CallTool(ctx, productID, toolName, toolArgs)
if err == nil {
return result, nil
}
if legacyAlias == "" {
return nil, err
}
if !isToolNotRegisteredError(err) && !isLegacyGrantSchemaMismatchError(err, toolArgs, legacyArgs) {
return nil, err
}
return c.CallTool(ctx, productID, legacyAlias, legacyArgs)
}
func isEmptyToolResult(result *edition.ToolResult) bool {
if result == nil || len(result.Content) == 0 {
return true
}
for _, block := range result.Content {
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
return false
}
}
return true
}
// isToolNotRegisteredError reports whether err looks like a server-side
// tool-not-registered / tool-not-found classification. We match on a few
// conservative substrings rather than a structured error type because the
// upstream runner surfaces the server message as plain text.
func isToolNotRegisteredError(err error) bool {
if err == nil {
return false
}
msg := normalizedPATErrorText(err)
needles := []string{
"tool_not_found",
"mcp_tool_not_found",
"tool not found",
"tool not registered",
"tool not exist",
"tool does not exist",
"unknown tool",
"no such tool",
"未找到指定工具",
"未找到工具",
"工具不存在",
}
for _, needle := range needles {
if strings.Contains(msg, needle) {
return true
}
}
return false
}
func isLegacyGrantSchemaMismatchError(err error, toolArgs, legacyArgs map[string]any) bool {
if err == nil || !hasScopeKeyShapeMismatch(toolArgs, legacyArgs) {
return false
}
if apperrors.IsPATError(err) {
return false
}
msg := normalizedPATErrorText(err)
if containsAny(msg,
"pat_no_permission",
"pat_low_risk_no_permission",
"pat_medium_risk_no_permission",
"pat_high_risk_no_permission",
"pat_scope_auth_required",
"agent_code_not_exists",
"requiredscopes",
"missingscope",
"missing_scope",
"insufficient_scope",
) {
return false
}
if !containsAny(msg, "scope", "scopes") {
return false
}
if !containsAny(msg,
"param_error",
"参数错误",
"parameter",
"validation",
"required",
"missing",
"unknown",
"unexpected",
"invalid",
"unmarshal",
) {
return false
}
if containsAny(msg,
"permission denied",
"no permission",
"forbidden",
"unauthorized",
"auth required",
"无权限",
"未授权",
"pat_medium_risk_no_permission",
) {
return false
}
return true
}
func hasScopeKeyShapeMismatch(toolArgs, legacyArgs map[string]any) bool {
if toolArgs == nil || legacyArgs == nil {
return false
}
_, hasCanonicalPlural := toolArgs["scopes"]
_, hasCanonicalSingular := toolArgs["scope"]
_, hasLegacyPlural := legacyArgs["scopes"]
_, hasLegacySingular := legacyArgs["scope"]
return hasCanonicalPlural && !hasCanonicalSingular && hasLegacySingular && !hasLegacyPlural
}
func normalizedPATErrorText(err error) string {
if err == nil {
return ""
}
parts := []string{strings.ToLower(err.Error())}
var typed *apperrors.Error
if stderrors.As(err, &typed) && typed != nil {
parts = append(parts,
strings.ToLower(typed.Reason),
strings.ToLower(typed.ServerDiag.ServerErrorCode),
strings.ToLower(typed.ServerDiag.TechnicalDetail),
strings.ToLower(typed.Hint),
)
}
return strings.Join(parts, " ")
}
func containsAny(msg string, needles ...string) bool {
for _, needle := range needles {
if strings.Contains(msg, needle) {
return true
}
}
return false
}
// handleToolResult processes a ToolResult and writes output to stdout.
func handleToolResult(result *edition.ToolResult) error {
if result == nil {
@@ -128,10 +420,6 @@ func handleToolResult(result *edition.ToolResult) error {
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
data, _ := json.Marshal(result)
return fmt.Errorf("empty PAT authorization result: %s", string(data))
}
+613
View File
@@ -0,0 +1,613 @@
// 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"
"errors"
"strings"
"sync"
"testing"
"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"
)
// fakeToolCaller captures the toolArgs passed to CallTool so tests can
// assert how the two-tier --agentCode / DINGTALK_DWS_AGENTCODE / error
// resolver feeds into the outgoing MCP argv.
type fakeToolCaller struct {
mu sync.Mutex
dryRun bool
gotTool string
gotArgs map[string]any
callN int
resultOK bool
}
func (f *fakeToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.callN++
f.gotTool = toolName
// defensive copy — RunE / runApply may mutate the map after return
f.gotArgs = make(map[string]any, len(args))
for k, v := range args {
f.gotArgs[k] = v
}
// Empty success payload keeps handleToolResult / emitApplyResult happy
// without triggering PAT classification in errors.ClassifyMCPResponseText.
if f.resultOK {
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"data":{}}`}}}, nil
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"data":{"authRequestId":"req-ok"}}`}}}, nil
}
func (f *fakeToolCaller) Format() string { return "json" }
func (f *fakeToolCaller) DryRun() bool { return f.dryRun }
type recordedToolCall struct {
tool string
args map[string]any
}
type fallbackToolCaller struct {
calls []recordedToolCall
}
func (f *fallbackToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for k, v := range args {
copied[k] = v
}
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
if len(f.calls) == 1 {
return &edition.ToolResult{}, nil
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"data":{"authRequestId":"req-ok"}}`}}}, nil
}
func (f *fallbackToolCaller) Format() string { return "json" }
func (f *fallbackToolCaller) DryRun() bool { return false }
type fallbackErrorToolCaller struct {
calls []recordedToolCall
}
func (f *fallbackErrorToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for k, v := range args {
copied[k] = v
}
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
if len(f.calls) == 1 {
return nil, errors.New("pat chmod failed: business error: PARAM_ERROR - 未找到指定工具")
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"data":{"authRequestId":"req-ok"}}`}}}, nil
}
func (f *fallbackErrorToolCaller) Format() string { return "json" }
func (f *fallbackErrorToolCaller) DryRun() bool { return false }
type fallbackSchemaMismatchToolCaller struct {
calls []recordedToolCall
}
func (f *fallbackSchemaMismatchToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for k, v := range args {
copied[k] = v
}
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
if len(f.calls) == 1 {
return nil, apperrors.NewAPI("business error: success=false",
apperrors.WithReason("business_error"),
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
ServerErrorCode: "PARAM_ERROR",
TechnicalDetail: `input schema validation failed: unknown field "scopes"; missing required field "scope"`,
}),
)
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"data":{"authRequestId":"req-ok"}}`}}}, nil
}
func (f *fallbackSchemaMismatchToolCaller) Format() string { return "json" }
func (f *fallbackSchemaMismatchToolCaller) DryRun() bool { return false }
type fallbackPermissionDeniedToolCaller struct {
calls []recordedToolCall
}
func (f *fallbackPermissionDeniedToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for k, v := range args {
copied[k] = v
}
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
return nil, apperrors.NewAPI("business error: success=false",
apperrors.WithReason("business_error"),
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
ServerErrorCode: "PAT_MEDIUM_RISK_NO_PERMISSION",
TechnicalDetail: "permission denied for scope chat.message:send",
}),
)
}
func (f *fallbackPermissionDeniedToolCaller) Format() string { return "json" }
func (f *fallbackPermissionDeniedToolCaller) DryRun() bool { return false }
type fallbackPATErrorToolCaller struct {
calls []recordedToolCall
}
func (f *fallbackPATErrorToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for k, v := range args {
copied[k] = v
}
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
return nil, &apperrors.PATError{RawJSON: `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED","data":{"missingScope":"mail:send"}}`}
}
func (f *fallbackPATErrorToolCaller) Format() string { return "json" }
func (f *fallbackPATErrorToolCaller) DryRun() bool { return false }
type fallbackPATContractErrorToolCaller struct {
calls []recordedToolCall
}
func (f *fallbackPATContractErrorToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for k, v := range args {
copied[k] = v
}
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
return nil, apperrors.NewAPI("business error: success=false",
apperrors.WithReason("business_error"),
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
ServerErrorCode: "PAT_SCOPE_AUTH_REQUIRED",
TechnicalDetail: `missingScope mail:send`,
}),
)
}
func (f *fallbackPATContractErrorToolCaller) Format() string { return "json" }
func (f *fallbackPATContractErrorToolCaller) DryRun() bool { return false }
func stringSliceArgEqual(got any, want []string) bool {
gotSlice, ok := got.([]string)
if !ok || len(gotSlice) != len(want) {
return false
}
for i := range want {
if gotSlice[i] != want[i] {
return false
}
}
return true
}
// buildChmod returns a freshly constructed chmod cobra.Command wired to
// fake. Using the factory (instead of a package-level var) keeps every
// subtest hermetic and matches the upstream shared-state fix in PR #129.
func buildChmod(t *testing.T, fake *fakeToolCaller) *cobra.Command {
t.Helper()
return newChmodCommand(fake)
}
// ---------------------------------------------------------------------------
// T1 · Agent-code env fallback tests
// ---------------------------------------------------------------------------
// TestChmod_agentCode_env_fallback verifies that when --agentCode is
// omitted but DINGTALK_DWS_AGENTCODE is exported, the resolver picks
// the env value up and forwards it verbatim in the MCP argv.
func TestChmod_agentCode_env_fallback(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
// grant-type=once → no session-id needed; keeps the test hermetic.
_ = cmd.Flags().Set("grant-type", "once")
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
t.Fatalf("chmod RunE error = %v (must not report flag missing)", err)
}
if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
t.Fatalf("agentCode in argv = %v, want %q (env fallback)", got, "qoderwork")
}
if got := fake.gotArgs["scopes"]; !stringSliceArgEqual(got, []string{"aitable.record:read"}) {
t.Fatalf("scopes in argv = %#v, want %#v", got, []string{"aitable.record:read"})
}
if _, ok := fake.gotArgs["scope"]; ok {
t.Fatalf("unexpected legacy singular scope arg in argv: %#v", fake.gotArgs)
}
}
func TestCallPATToolWithLegacyFallback_emptyCanonicalResultDoesNotRetryLegacyAlias(t *testing.T) {
fake := &fallbackToolCaller{}
canonicalArgs := map[string]any{
"agentCode": "qoderwork",
"scopes": []string{"aitable.record:read"},
"grantType": "permanent",
}
legacyArgs := map[string]any{
"agentCode": "qoderwork",
"scope": []string{"aitable.record:read"},
"grantType": "permanent",
}
result, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
if err != nil {
t.Fatalf("callPATToolWithLegacyFallback error = %v", err)
}
if !isEmptyToolResult(result) {
t.Fatalf("expected original empty canonical result, got %#v", result)
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool call count = %d, want 1", len(fake.calls))
}
if fake.calls[0].tool != patGrantToolName {
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patGrantToolName)
}
if _, ok := fake.calls[0].args["scopes"]; !ok {
t.Fatalf("canonical args missing scopes: %#v", fake.calls[0].args)
}
if _, ok := fake.calls[0].args["scope"]; ok {
t.Fatalf("canonical args should not use legacy scope: %#v", fake.calls[0].args)
}
}
func TestChmod_emptyCanonicalResultReturnsError(t *testing.T) {
fake := &fallbackToolCaller{}
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("agentCode", "qoderwork")
_ = cmd.Flags().Set("grant-type", "permanent")
err := cmd.RunE(cmd, []string{"aitable.record:read"})
if err == nil {
t.Fatal("chmod RunE error = nil, want empty PAT authorization result")
}
if !strings.Contains(err.Error(), "empty PAT authorization result") {
t.Fatalf("chmod RunE error = %q, want empty PAT authorization result", err.Error())
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool call count = %d, want 1", len(fake.calls))
}
if fake.calls[0].tool != patGrantToolName {
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patGrantToolName)
}
}
func TestCallPATToolWithLegacyFallback_toolNotFoundRetriesLegacyAlias(t *testing.T) {
fake := &fallbackErrorToolCaller{}
canonicalArgs := map[string]any{
"agentCode": "qoderwork",
"scopes": []string{"aitable.record:read"},
"grantType": "permanent",
}
legacyArgs := map[string]any{
"agentCode": "qoderwork",
"scope": []string{"aitable.record:read"},
"grantType": "permanent",
}
result, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
if err != nil {
t.Fatalf("callPATToolWithLegacyFallback error = %v", err)
}
if isEmptyToolResult(result) {
t.Fatalf("fallback result is empty: %#v", result)
}
if len(fake.calls) != 2 {
t.Fatalf("CallTool call count = %d, want 2", len(fake.calls))
}
if fake.calls[0].tool != patGrantToolName {
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patGrantToolName)
}
if fake.calls[1].tool != patGrantToolNameLegacyAlias {
t.Fatalf("fallback tool = %q, want %q", fake.calls[1].tool, patGrantToolNameLegacyAlias)
}
if _, ok := fake.calls[1].args["scope"]; !ok {
t.Fatalf("legacy args missing scope: %#v", fake.calls[1].args)
}
if _, ok := fake.calls[1].args["scopes"]; ok {
t.Fatalf("legacy args should not use canonical scopes: %#v", fake.calls[1].args)
}
}
func TestCallPATToolWithLegacyFallback_schemaMismatchRetriesLegacyAlias(t *testing.T) {
fake := &fallbackSchemaMismatchToolCaller{}
canonicalArgs := map[string]any{
"agentCode": "qoderwork",
"scopes": []string{"aitable.record:read"},
"grantType": "permanent",
}
legacyArgs := map[string]any{
"agentCode": "qoderwork",
"scope": []string{"aitable.record:read"},
"grantType": "permanent",
}
result, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
if err != nil {
t.Fatalf("callPATToolWithLegacyFallback error = %v", err)
}
if isEmptyToolResult(result) {
t.Fatalf("fallback result is empty: %#v", result)
}
if len(fake.calls) != 2 {
t.Fatalf("CallTool call count = %d, want 2", len(fake.calls))
}
if fake.calls[0].tool != patGrantToolName {
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patGrantToolName)
}
if fake.calls[1].tool != patGrantToolNameLegacyAlias {
t.Fatalf("fallback tool = %q, want %q", fake.calls[1].tool, patGrantToolNameLegacyAlias)
}
if _, ok := fake.calls[1].args["scope"]; !ok {
t.Fatalf("legacy args missing scope: %#v", fake.calls[1].args)
}
}
func TestCallPATToolWithLegacyFallback_permissionDeniedDoesNotRetryLegacyAlias(t *testing.T) {
fake := &fallbackPermissionDeniedToolCaller{}
canonicalArgs := map[string]any{
"agentCode": "qoderwork",
"scopes": []string{"chat.message:send"},
"grantType": "once",
}
legacyArgs := map[string]any{
"agentCode": "qoderwork",
"scope": []string{"chat.message:send"},
"grantType": "once",
}
_, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
if err == nil {
t.Fatal("callPATToolWithLegacyFallback error = nil, want original permission denial")
}
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error type = %T, want *errors.Error", err)
}
if typed.ServerDiag.ServerErrorCode != "PAT_MEDIUM_RISK_NO_PERMISSION" {
t.Fatalf("ServerErrorCode = %q, want PAT_MEDIUM_RISK_NO_PERMISSION", typed.ServerDiag.ServerErrorCode)
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool call count = %d, want 1", len(fake.calls))
}
}
func TestCallPATToolWithLegacyFallback_patErrorDoesNotRetryLegacyAlias(t *testing.T) {
fake := &fallbackPATErrorToolCaller{}
canonicalArgs := map[string]any{
"agentCode": "qoderwork",
"scopes": []string{"mail:send"},
"grantType": "once",
}
legacyArgs := map[string]any{
"agentCode": "qoderwork",
"scope": []string{"mail:send"},
"grantType": "once",
}
_, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
if err == nil {
t.Fatal("callPATToolWithLegacyFallback error = nil, want PATError")
}
if !apperrors.IsPATError(err) {
t.Fatalf("expected PATError, got %T", err)
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool call count = %d, want 1", len(fake.calls))
}
}
func TestCallPATToolWithLegacyFallback_patContractErrorDoesNotRetryLegacyAlias(t *testing.T) {
fake := &fallbackPATContractErrorToolCaller{}
canonicalArgs := map[string]any{
"agentCode": "qoderwork",
"scopes": []string{"mail:send"},
"grantType": "once",
}
legacyArgs := map[string]any{
"agentCode": "qoderwork",
"scope": []string{"mail:send"},
"grantType": "once",
}
_, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
if err == nil {
t.Fatal("callPATToolWithLegacyFallback error = nil, want original PAT contract error")
}
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error type = %T, want *errors.Error", err)
}
if typed.ServerDiag.ServerErrorCode != "PAT_SCOPE_AUTH_REQUIRED" {
t.Fatalf("ServerErrorCode = %q, want PAT_SCOPE_AUTH_REQUIRED", typed.ServerDiag.ServerErrorCode)
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool call count = %d, want 1", len(fake.calls))
}
}
func TestIsToolNotRegisteredError_ChineseGatewayMessage(t *testing.T) {
err := errors.New("pat chmod failed: business error: PARAM_ERROR - 未找到指定工具")
if !isToolNotRegisteredError(err) {
t.Fatalf("isToolNotRegisteredError(%q) = false, want true", err.Error())
}
}
func TestIsToolNotRegisteredError_ChineseGatewayDiagnostics(t *testing.T) {
err := apperrors.NewAPI("business error: success=false",
apperrors.WithReason("business_error"),
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
ServerErrorCode: "PARAM_ERROR",
TechnicalDetail: "Tool metadata API error: PARAM_ERROR - 未找到指定工具",
}),
)
if !isToolNotRegisteredError(err) {
t.Fatalf("isToolNotRegisteredError(%q) = false, want true", err.Error())
}
}
func TestHandleToolResult_emptyResultReturnsError(t *testing.T) {
err := handleToolResult(&edition.ToolResult{})
if err == nil {
t.Fatal("handleToolResult error = nil, want empty PAT authorization result error")
}
if !strings.Contains(err.Error(), "empty PAT authorization result") {
t.Fatalf("handleToolResult error = %q, want empty PAT authorization result", err.Error())
}
}
// TestChmod_agentCode_env_invalid verifies that a malformed
// DINGTALK_DWS_AGENTCODE value (whitespace, shell metacharacters) is
// rejected by the regex gate in validateAgentCode before any MCP call
// is attempted.
func TestChmod_agentCode_env_invalid(t *testing.T) {
t.Setenv(agentCodeEnv, "bad value with space!")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
_ = cmd.Flags().Set("grant-type", "once")
err := cmd.RunE(cmd, []string{"aitable.record:read"})
if err == nil {
t.Fatalf("expected validateAgentCode error, got nil")
}
if !strings.Contains(err.Error(), "invalid agentCode") {
t.Fatalf("error = %q, want to mention 'invalid agentCode'", err.Error())
}
if !strings.Contains(err.Error(), agentCodeEnv) {
t.Fatalf("error = %q, want to attribute to %s env", err.Error(), agentCodeEnv)
}
if fake.callN != 0 {
t.Fatalf("CallTool was invoked %d times; validator must short-circuit before MCP", fake.callN)
}
}
// TestChmod_agentCode_flag_wins_over_env verifies the Priority-1 contract
// of resolveAgentCode: when both the flag and the env are set, the flag
// wins and env is silently ignored (no warning needed because the flag is
// the explicit, scripted intent).
func TestChmod_agentCode_flag_wins_over_env(t *testing.T) {
t.Setenv(agentCodeEnv, "envval")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("agentCode", "flagval")
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if got := fake.gotArgs["agentCode"]; got != "flagval" {
t.Fatalf("agentCode in argv = %v, want %q (flag must win over env)", got, "flagval")
}
}
// TestChmod_agentCode_legacy_env_not_recognized is a reverse-guard: after
// the SSOT hard-removal of the DWS_AGENTCODE alias, exporting only the
// legacy env MUST NOT satisfy the --agentCode requirement. The command
// is expected to fail with an error that explicitly names the canonical
// DINGTALK_DWS_AGENTCODE env, and MUST NOT mention DWS_AGENTCODE as a
// usable fallback. No MCP call is permitted.
func TestChmod_agentCode_legacy_env_not_recognized(t *testing.T) {
t.Setenv(agentCodeEnv, "")
t.Setenv("DWS_AGENTCODE", "legacyval")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
_ = cmd.Flags().Set("grant-type", "once")
err := cmd.RunE(cmd, []string{"aitable.record:read"})
if err == nil {
t.Fatalf("expected hard error when only legacy DWS_AGENTCODE is set, got nil")
}
if !strings.Contains(err.Error(), "DINGTALK_DWS_AGENTCODE") {
t.Fatalf("error = %q, want to name canonical DINGTALK_DWS_AGENTCODE env", err.Error())
}
// Defensive: the canonical env naturally contains the substring
// "DWS_AGENTCODE" as part of "DINGTALK_DWS_AGENTCODE"; the above
// assertion plus the absence check below precisely guard against
// advertising the legacy alias as usable.
hint := strings.ReplaceAll(err.Error(), "DINGTALK_DWS_AGENTCODE", "")
if strings.Contains(hint, "DWS_AGENTCODE") {
t.Fatalf("error = %q must not advertise DWS_AGENTCODE as usable", err.Error())
}
if fake.callN != 0 {
t.Fatalf("CallTool was invoked %d times; legacy env must not satisfy --agentCode", fake.callN)
}
}
// ---------------------------------------------------------------------------
// validateAgentCode / resolveAgentCodeFromEnv unit tests
// ---------------------------------------------------------------------------
func TestValidateAgentCode(t *testing.T) {
t.Parallel()
cases := []struct {
in string
wantErr bool
}{
{"qoderwork", false},
{"agt-abc123", false},
{"Agt_Xyz-09", false},
{"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", false}, // 64 chars
{"", true},
{"bad value", true},
{"bad!chars", true},
{"中文不行", true},
{"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789X", true}, // 65
}
for _, tc := range cases {
err := validateAgentCode(tc.in)
if (err != nil) != tc.wantErr {
t.Errorf("validateAgentCode(%q) err=%v, wantErr=%v", tc.in, err, tc.wantErr)
}
}
}
func TestResolveAgentCodeFromEnv(t *testing.T) {
// Not parallel: mutates process env.
// DINGTALK_DWS_AGENTCODE is honoured and trimmed.
t.Setenv(agentCodeEnv, " qoderwork ")
if code, src := resolveAgentCodeFromEnv(); code != "qoderwork" || src != agentCodeEnv {
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want (%q, %q)",
code, src, "qoderwork", agentCodeEnv)
}
// Empty primary → ("", "").
t.Setenv(agentCodeEnv, "")
if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" {
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty", code, src)
}
// Reverse-guard: legacy DWS_AGENTCODE MUST NOT be picked up when the
// canonical env is unset — it was hard-removed as a legacy alias.
t.Setenv(agentCodeEnv, "")
t.Setenv("DWS_AGENTCODE", "legacy")
if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" {
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty — legacy DWS_AGENTCODE must be ignored",
code, src)
}
}
+28 -1
View File
@@ -30,10 +30,37 @@ func RegisterCommands(root *cobra.Command, c edition.ToolCaller) {
Long: `管理行为授权(PAT)。
命令结构:
dws pat chmod <scope>... 授予指定权限`,
dws pat chmod <scope>... 授予指定权限
dws pat browser-policy 配置 PAT 浏览器打开策略
能力说明:
--format 只控制 PAT 撞墙时的输出形态;当 --format json 时,
CLI 只返回结构化 JSON,不混入非结构化文本。
浏览器是否打开由本地 PAT 策略单独决定,与 json / non-json 独立。
生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。
写入 agent 策略需显式传 --agentCode;不传则写入全局默认策略。
Host-owned PAT 开关:
当且仅当环境变量 DINGTALK_DWS_AGENTCODE 非空时,CLI 命中 PAT
固定以 stderr JSON + exit=4 的 host-owned 形式返回,
由宿主处理全部 UI / 交互 / 回调节奏 / 重试逻辑,
CLI 侧不再拉起任何本地浏览器 / 轮询。
服务端路由标签 claw-type(开源构建硬编码):
开源构建在所有出站 MCP 请求上恒定注入 claw-type: openClaw,
与 DINGTALK_AGENT / 宿主环境解耦,与历史 main 行为一致。
hostControl.clawType 也会回填该值,便于宿主侧审计/路由。
DINGTALK_AGENT(可选,仅供 x-dingtalk-agent 使用):
如设置,将原样注入 HTTP 请求头 x-dingtalk-agent,
便于上游按业务 Agent 名称区分流量。
它不参与 claw-type 派生,也不参与 host-owned PAT 判定。
DWS_CHANNEL 只用于上游 channelCode。`,
RunE: cmdutil.GroupRunE,
}
patCmd.AddCommand(newChmodCommand(c))
patCmd.AddCommand(newBrowserPolicyCommand())
root.AddCommand(patCmd)
}
-13
View File
@@ -572,16 +572,3 @@ func TestSyncSkills(t *testing.T) {
}
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr))
}
func containsSubstring(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
+10 -1
View File
@@ -19,7 +19,15 @@ const (
)
// knownSkillDirs lists all known Agent skill directories (relative to $HOME).
// Kept in sync with build/npm/install.js AGENT_DIRS.
// Kept in sync with:
// - build/npm/install.js AGENT_DIRS
// - scripts/install.sh for-in list
// - scripts/install.ps1 $AgentDirs
// - scripts/install-skills.sh for-in list
// - build/homebrew.rb.tmpl targets
// - test/scripts/package_script_test.go expectedPackagedSkillTargets
// - scripts/release/verify-package-managers.sh HOME_AGENT_PARENTS / HOME_SKILL_TARGETS
//
// The first entry (.agents/skills) is always updated; subsequent entries are
// only updated when their parent directory already exists.
var knownSkillDirs = []string{
@@ -36,6 +44,7 @@ var knownSkillDirs = []string{
".kiro/skills",
".trae/skills",
".openclaw/skills",
".hermes/skills",
}
// skillDirBlacklist contains parent directories whose skills are managed by
+18 -1
View File
@@ -52,9 +52,19 @@ import (
// leaf leaf (hc priority ≤ dyn) dynamic wins (no-op)
// leaf leaf (hc priority > dyn) hardcoded replaces dynamic
// group group recurse
// leaf group dynamic wins, warn
// leaf group (hc priority > dyn) hardcoded group replaces dyn leaf
// leaf group (hc priority ≤ dyn) dynamic wins, warn
// group leaf dynamic wins, warn
//
// The "leaf vs group" priority promotion path (added for issue #164) covers
// the case where the envelope exposes a single tool at a CLI path but the
// hardcoded helper restructures that path into a group of richer subcommands
// (e.g. `chat group members` published as a leaf for `get_group_members`,
// but the helper provides `list / add / remove / add-bot` siblings). Without
// this path the helper subtree is silently dropped on every release, which
// is exactly the regression the OverridePriority annotation exists to prevent
// for leaf-vs-leaf — extending it to leaf-vs-group keeps the contract honest.
//
// MergeHardcodedLeaves mutates dynamicRoot in place and returns it so callers
// can chain. hardcodedRoot is treated as a donor: grafted children are
// detached from it so their cobra parent pointer points at the new parent.
@@ -81,6 +91,13 @@ func MergeHardcodedLeaves(dynamicRoot, hardcodedRoot *cobra.Command) *cobra.Comm
// else: envelope is authority; hardcoded leaf is ignored.
case !IsLeafCmd(hc) && !IsLeafCmd(dyn):
MergeHardcodedLeaves(dyn, hc)
case IsLeafCmd(dyn) && !IsLeafCmd(hc) && OverridePriority(hc) > OverridePriority(dyn):
// Helper restructures a dynamic leaf into a richer subcommand
// group; honour the explicit OverridePriority opt-in just like
// the leaf-vs-leaf case.
hardcodedRoot.RemoveCommand(hc)
dynamicRoot.RemoveCommand(dyn)
dynamicRoot.AddCommand(hc)
default:
slog.Warn("overlay: shape mismatch, keeping dynamic",
"name", hc.Name(),
+60
View File
@@ -182,6 +182,66 @@ func TestMergeHardcodedLeaves_ShapeMismatch_KeepsDynamic(t *testing.T) {
}
}
// TestMergeHardcodedLeaves_HigherPriorityHardcodedGroupOverridesDynamicLeaf
// covers the leaf↔group shape-mismatch promotion path added for issue #164:
// when the envelope publishes a single tool at a CLI path (leaf) but the
// helper restructures it into a richer subcommand group, the helper group
// must win when it carries OverridePriority strictly higher than the
// dynamic leaf — otherwise the helper subtree (e.g. `chat group members
// list / add / remove / add-bot`) is silently dropped and the regression
// the priority annotation is meant to prevent re-emerges in shape-mismatch
// form.
func TestMergeHardcodedLeaves_HigherPriorityHardcodedGroupOverridesDynamicLeaf(t *testing.T) {
t.Parallel()
dynLeaf := newLeaf("shared", "dynamic")
dyn := newGroup("root", dynLeaf)
hcGroup := newGroup("shared",
newLeaf("list", "hardcoded"),
newLeaf("add", "hardcoded"),
)
SetOverridePriority(hcGroup, 100)
hc := newGroup("root", hcGroup)
MergeHardcodedLeaves(dyn, hc)
got := findChildByName(dyn, "shared")
if got == nil {
t.Fatal("expected `shared` on dyn after merge")
}
if got != hcGroup {
t.Fatal("expected hardcoded group to replace dynamic leaf")
}
if findChildByName(got, "list") == nil {
t.Fatal("expected hardcoded subtree leaf `list` to be reachable")
}
if findChildByName(got, "add") == nil {
t.Fatal("expected hardcoded subtree leaf `add` to be reachable")
}
if findChildByName(hc, "shared") != nil {
t.Fatal("expected hardcoded `shared` to be detached from hc after replacement")
}
}
// TestMergeHardcodedLeaves_EqualPriorityShapeMismatchKeepsDynamic guards the
// boundary: only a strictly-higher priority promotes the helper group.
// Equal priority must still keep the envelope as authority (warn case).
func TestMergeHardcodedLeaves_EqualPriorityShapeMismatchKeepsDynamic(t *testing.T) {
t.Parallel()
dynLeaf := newLeaf("shared", "dynamic")
SetOverridePriority(dynLeaf, 100)
dyn := newGroup("root", dynLeaf)
hcGroup := newGroup("shared", newLeaf("list", "hardcoded"))
SetOverridePriority(hcGroup, 100)
hc := newGroup("root", hcGroup)
MergeHardcodedLeaves(dyn, hc)
got := findChildByName(dyn, "shared")
if got != dynLeaf {
t.Fatalf("equal priorities + shape mismatch must keep dynamic leaf; got %+v", got)
}
}
func TestIsLeafCmd(t *testing.T) {
t.Parallel()
leaf := newLeaf("x", "")
+17
View File
@@ -74,6 +74,23 @@ const (
DefaultPartition = "default/default"
)
// EditionPartition returns the cache partition for a given edition name.
// The open-source core (name == "" or "open") uses DefaultPartition; every
// other edition gets its own namespace to prevent cross-edition data
// leakage in the disk cache.
//
// This helper takes the edition name as a parameter instead of calling
// edition.Get() so that pkg/config remains a leaf dependency — importable
// from internal/cli, internal/app, internal/cache, etc. without risking
// import cycles.
func EditionPartition(name string) string {
name = strings.TrimSpace(name)
if name == "" || name == "open" {
return DefaultPartition
}
return name + "/default"
}
// ── Auth flow timeouts ──────────────────────────────────────────────────
const (
+22
View File
@@ -74,6 +74,28 @@ func TestDefaultPartition(t *testing.T) {
}
}
func TestEditionPartition(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
want string
}{
{"empty maps to default", "", DefaultPartition},
{"open maps to default", "open", DefaultPartition},
{"whitespace is trimmed", " wukong ", "wukong/default"},
{"wukong overlay", "wukong", "wukong/default"},
{"arbitrary edition", "internal-lab", "internal-lab/default"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := EditionPartition(tc.input); got != tc.want {
t.Fatalf("EditionPartition(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
func TestManualTokenExpiry(t *testing.T) {
t.Parallel()
if ManualTokenExpiry <= 0 {
+14 -3
View File
@@ -13,9 +13,20 @@
package edition
// DefaultOSSClawType is the wire value for request header claw-type in
// the open-source build. It is intentionally hard-wired — the open-source
// CLI does NOT derive claw-type from DINGTALK_AGENT or any other caller
// input, so third-party hosts get a predictable header regardless of
// their environment.
const DefaultOSSClawType = "openClaw"
// defaultHooks returns the open-source edition defaults.
// All function hooks are nil, which the internal code interprets as
// "use standard open-source behaviour".
//
// MergeHeaders is the only hook that ships with behaviour: it pins the
// `claw-type` request header to DefaultOSSClawType so every open-source
// MCP request carries the same stable routing tag. All other fields are
// nil — the internal code interprets nil as "use standard open-source
// behaviour".
func defaultHooks() *Hooks {
return &Hooks{
Name: "open",
@@ -23,7 +34,7 @@ func defaultHooks() *Hooks {
if base == nil {
base = make(map[string]string)
}
base["claw-type"] = "openClaw"
base["claw-type"] = DefaultOSSClawType
return base
},
}
+2 -1
View File
@@ -116,7 +116,8 @@ install_skills_to_root() {
".amp/skills" \
".kiro/skills" \
".trae/skills" \
".openclaw/skills"
".openclaw/skills" \
".hermes/skills"
do
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
+2 -1
View File
@@ -44,7 +44,8 @@ $AgentDirs = @(
".amp\skills",
".kiro\skills",
".trae\skills",
".openclaw\skills"
".openclaw\skills",
".hermes\skills"
)
# ── Helpers ──────────────────────────────────────────────────────────────────
+2 -1
View File
@@ -201,7 +201,8 @@ install_skills_to_homes() {
".amp/skills" \
".kiro/skills" \
".trae/skills" \
".openclaw/skills"
".openclaw/skills" \
".hermes/skills"
do
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
@@ -37,6 +37,7 @@ HOME_AGENT_PARENTS="
.kiro
.trae
.openclaw
.hermes
"
HOME_SKILL_TARGETS="
.agents/skills/dws
@@ -52,6 +53,7 @@ HOME_SKILL_TARGETS="
.kiro/skills/dws
.trae/skills/dws
.openclaw/skills/dws
.hermes/skills/dws
"
cleanup() {
if command -v brew >/dev/null 2>&1; then
+3 -1
View File
@@ -1,6 +1,6 @@
---
name: dws
description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要时使用。
description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记/邮箱等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件时使用。
cli_version: ">=1.0.15"
---
@@ -36,6 +36,7 @@ cli_version: ">=1.0.15"
| `minutes` | AI听记:听记列表/摘要/关键词/转写/待办/思维导图/发言人/热词/上传 | [minutes.md](./references/products/minutes.md) |
| `oa` | OA审批:待办/我发起的/表单模板/详情/审批流水/同意/拒绝/撤销 | [oa.md](./references/products/oa.md) |
| `report` | 日志:按模版创建/收件箱/已发送/模版查看/详情/已读统计 | [report.md](./references/products/report.md) |
| `mail` | 邮箱:邮箱地址查询/邮件搜索(KQL)/邮件详情/发送邮件 | [mail.md](./references/products/mail.md) |
| `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
## 意图判断决策树
@@ -50,6 +51,7 @@ cli_version: ">=1.0.15"
用户提到"钉钉文档/云文档/知识库/读写文档/块级编辑/文档评论/文档复制移动" → `doc`
用户提到"云盘/文件存储/文件上传下载/文件夹" → `drive`
用户提到"听记/AI听记/会议纪要/转写/摘要/思维导图/发言人/热词" → `minutes`
用户提到"邮箱/邮件/发邮件/收邮件/搜邮件/查邮件" → `mail`
用户提到"审批/请假/报销/出差/加班/同意/拒绝/撤销审批" → `oa`
用户提到"日志/日报/周报/日志统计/写日报/提交周报/发日志/填日志" → `report`
用户提到"待办/TODO/任务提醒/循环待办" → `todo`
+2 -2
View File
@@ -91,7 +91,7 @@
```bash
# 1. 搜索同事 userId
dws contact user search --keyword "张三" --format json
dws contact user search --query "张三" --format json
# 2. 创建日程
dws calendar event create --title "会议" \
@@ -107,7 +107,7 @@ dws calendar participant add --event <EVENT_ID> --users <USER_ID> --format json
```bash
# 1. 搜索同事 userId
dws contact user search --keyword "张三" --format json
dws contact user search --query "张三" --format json
# 2. 创建待办
dws todo task create --title "任务内容" --executors <USER_ID> --format json
+7 -3
View File
@@ -32,12 +32,16 @@ Flags:
Usage:
dws attendance summary [flags]
Example:
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00"
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type month
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type week
Flags:
--date string 工作日期, 格式 yyyy-MM-dd HH:mm:ss (必填)
--user string 钉钉用户 ID (必填)
--date string 工作日期, 格式 yyyy-MM-dd HH:mm:ss (必填)
--stats-type string 统计类型:week(周统计)或 month(月统计)(必填,钉钉服务端业务层强制要求;CLI 层会直接拒绝缺失/非法值)
--user string 钉钉用户 ID (必填)
```
> ⚠️ **重要**:`--stats-type` 在钉钉 schema 中标记为 `required: []`(看似可选),但服务端业务层**强制要求**,不传服务端会回 `C0002 / 统计类型错误`。CLI 已在客户端层做了 fail-fast:缺失或非 `week`/`month` 的取值会直接被 CLI 拒绝,不会发出请求。
### 查询考勤组与考勤规则
```
Usage:
+47 -12
View File
@@ -60,20 +60,22 @@ Flags:
---
## group members — 查看群成员列表
## group members list — 查看群成员列表
分页查询指定群聊的成员。
```
Usage:
dws chat group members [flags]
dws chat group members list [flags]
Example:
dws chat group members --id <openconversation_id>
dws chat group members list --id <openconversation_id>
Flags:
--cursor string 分页游标,首次从 0 开始
--id string 群 ID / openconversation_id (必填)
```
> ⚠️ 注意:v1.0.17 起 list 是显式子命令;旧用法 `dws chat group members --id ...` 已不再支持,请改用 `dws chat group members list --id ...`。
---
## group members add — 添加群成员
@@ -181,7 +183,7 @@ Flags:
## message send — 以当前用户身份发消息
--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。可选 --title 作为消息标题。
--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。必须提供 --title 作为消息标题。
--群聊时可选 --at-all @所有人,或 --at-users 指定成员(仅群聊时生效)。
--发送图片消息:指定 --media-id(通过 dt_media_upload 工具上传获得),自动设置 msgType=image,此时不需要传文本内容。
@@ -203,9 +205,10 @@ Flags:
--group string 群聊 openconversation_id(群聊时必填)
--user string 接收人 userId(单聊时与 --open-dingtalk-id 二选一)
--open-dingtalk-id string 接收人 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景)
--title string 消息标题(可选,默认「消息」)
--title string 消息标题(必填)
--at-all @所有人(仅群聊时生效,可选,默认 false)
--at-users string @指定成员的 userId 列表,逗号分隔(仅群聊时生效,可选)
--at-mobiles string @指定成员的手机号列表,逗号分隔(仅群聊时生效,可选)
--media-id string 图片 mediaId(通过 dt_media_upload 工具上传获得,需从返回链接中去除 _宽_高.格式 后缀并加上 @ 前缀),指定后发送图片消息,不需要传文本内容
--msg-type string 消息类型(可选,如 text/markdown/image/file;通常由 --text/--media-id/--dentry-id 自动推断)
--dentry-id string 钉盘文件 dentryId(发送钉盘文件消息时使用,需配合 --space-id)
@@ -218,7 +221,7 @@ Flags:
- --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>
- --at-all / --at-users / --at-mobiles 仅在 --group 群聊时生效;当设置--at-all时,消息内容中一定要包含对应的占位符<@all>;当设置--at-users userId1,userId2时,消息内容中一定要包含对应格式的占位符<@userId1> <@userId2>
- --media-id 指定图片 mediaId 时自动发送图片消息(msgType=image),不需要传 --text;图片单聊仅支持 --open-dingtalk-id,不支持 --user
- 发送钉盘文件消息:传 --dentry-id + --space-id(必要时配合 --file-name / --file-size / --file-type),msg-type 自动推断为 file
```
@@ -229,31 +232,63 @@ Flags:
拉取指定群聊或单聊的会话消息内容。
--group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
--group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。
```
Usage:
dws chat message list [flags]
Example:
# 拉取群聊中某个时间点之后的消息
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
# 拉取单聊消息(通过 userId)
dws chat message list --user <userId> --time "2025-03-01 00:00:00" --limit 50
# 拉取单聊消息(通过 openDingTalkId)
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 (必填)
--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` 的翻页方式与 `message list-all` **完全不同**,请勿混淆:
| 命令 | 翻页参数 | 翻页值来源 | 值格式 |
|------|---------|-----------|--------|
| `message list` | `--time` | 上一页结果中**最后一条消息的 `createTime` 字段** | `yyyy-MM-dd HH:mm:ss`(如 `"2025-03-01 14:30:00"`) |
| `message list-all` | `--cursor` | 上一页响应中的 `nextCursor` 字段 | 字符串(如 `"abc123token"`) |
**翻页步骤(message list):**
1. **首次请求**:指定起始时间
```bash
dws chat message list --group <id> --time "2025-03-01 00:00:00" --limit 50 --format json
```
2. **检查响应**:查看 `hasMore` 字段
- `hasMore: false` → 没有更多消息,翻页结束
- `hasMore: true` → 还有更多消息,继续下一步
3. **获取翻页时间**:取返回结果中**最后一条消息**的 `createTime` 字段值(如 `"2025-03-01 14:30:00"`)
4. **下一页请求**:将该 `createTime` 作为 `--time` 传入
```bash
dws chat message list --group <id> --time "2025-03-01 14:30:00" --limit 50 --format json
```
5. 重复步骤 2-4 直到 `hasMore: false`
> ⚠️ **常见错误**:
> - **不要把 `nextCursor` 传给 `--time`**:响应中的 `nextCursor` 字段(纯数字时间戳如 `1776684611219`)**不是给 `--time` 用的**。`--time` 只接受 `yyyy-MM-dd HH:mm:ss` 格式。将 `nextCursor` 传给 `--time` 会导致返回相同页面,陷入死循环。`nextCursor` 仅用于 `message list-all` 的 `--cursor` 参数。
> - **不要把 `nextCursor` 传给 `--forward`**:`--forward` 只接受 `true`(拉给定时间之后的消息)或 `false`(拉给定时间之前的消息),不是时间戳或游标参数。
---
## message list-all — 拉取指定时间范围内当前用户的所有会话消息
@@ -542,7 +577,7 @@ Flags:
用户说"建群/创建群聊" → `chat group create`
用户说"搜索群/找群" → `chat search`
用户说"群成员/看群里有谁" → `chat group members`
用户说"群成员/看群里有谁" → `chat group members list`
用户说"拉人进群/加群成员" → `chat group members add`
用户说"踢人/移除群成员" → `chat group members remove`
用户说"加机器人到群" → `chat group members add-bot`
@@ -601,10 +636,10 @@ dws chat message list-unread-conversations --count 20 --format json
dws chat message send --group <openconversation_id> --title "周报提醒" "请大家本周五前提交周报" --format json
# 4. 以个人身份单聊(通过 userId)
dws chat message send --user <userId> "你好" --format json
dws chat message send --user <userId> --title "问候" "你好" --format json
# 4b. 以个人身份单聊(通过 openDingTalkId,三方应用等无法获取 userId 时使用)
dws chat message send --open-dingtalk-id <openDingTalkId> "你好" --format json
dws chat message send --open-dingtalk-id <openDingTalkId> --title "问候" "你好" --format json
# 5. 机器人发群消息(Markdown)
dws chat message send-by-bot --robot-code <robot-code> \
+160
View File
@@ -0,0 +1,160 @@
# 邮箱 (mail) 命令参考
## 命令总览
### 查询可用邮箱地址
```
Usage:
dws mail mailbox list [flags]
Example:
dws mail mailbox list
```
**返回字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `mailboxes` | `List[]` | 邮箱列表,每条包含邮箱地址、账号类型、所属企业 |
### 搜索邮件 (KQL 语法)
```
Usage:
dws mail message search [flags]
Example:
dws mail message search --email user@company.com --query "subject:\"周报\"" --size 20
dws mail message search --email user@company.com --query "from:alice AND date>2025-06-01T00:00:00Z" --size 10
Flags:
--cursor string 邮件的起始偏移标识, 其值取自响应中的nextCursor字段。""表示从头开始
--email string 搜索目标邮箱地址 (必填)
--query string KQL 查询表达式 (必填), 其中 date 格式需遵循 ISO8601 规范
--size string 每页返回数量(最大限制 100, 默认 20) (必填),别名: --limit, --page-size
```
KQL 查询字段: date, size, tag, folderId, isRead, hasAttachments, subject, attachname, body, from, to
常用文件夹 ID: 1=已发送, 2=收件箱, 3=垃圾邮件, 5=草稿, 6=已删除
### KQL 查询字段说明
| 字段 | 类型 | 说明 | 正确示例 | 错误示例 |
|------|------|------|----------|----------|
| `date` | ISO8601 日期时间 | 邮件日期,支持 `>` `<` `>=` `<=` 比较运算符 | `date>2025-06-01T00:00:00Z` | `date>2025-06-01`(缺少时间部分) |
| `size` | 整数(字节数) | 邮件大小,支持 `>` `<` `>=` `<=` 比较运算符 | `size>1024` | `size>"1024"`(值不需要引号) |
| `tag` | 字符串 | 邮件标签 | `tag:important` | `tag:""` |
| `folderId` | 整数 | 文件夹 ID(1=已发送, 2=收件箱, 3=垃圾邮件, 5=草稿, 6=已删除) | `folderId:2` | `folderId:"收件箱"`(必须用数字 ID) |
| `isRead` | 布尔 `true`/`false` | 是否已读 | `isRead:false` | `isRead:0`、`isRead:"false"`(不支持数字或字符串形式) |
| `hasAttachments` | 布尔 `true`/`false` | 是否有附件 | `hasAttachments:true` | `hasAttachments:yes` |
| `subject` | 字符串 | 邮件主题,含空格须加双引号 | `subject:周报`、`subject:"项目 进展"` | `subject:项目 进展`(含空格未加引号) |
| `attachname` | 字符串 | 附件文件名,含空格须加双引号 | `attachname:report.pdf`、`attachname:"月度 报告.xlsx"` | `attachname:月度 报告.xlsx`(含空格未加引号) |
| `body` | 字符串 | 邮件正文内容,含空格须加双引号 | `body:会议纪要`、`body:"Q1 总结"` | `body:Q1 总结`(含空格未加引号) |
| `from` | 字符串(邮件地址或名称) | 发件人,支持:纯邮件地址、纯名称(含空格须加双引号)、`"名称<邮件地址>"` 格式 | `from:alice@company.com`、`from:"张 三"`、`from:"alice<a@b.com>"` | `from:张 三`(含空格未加引号) |
| `to` | 字符串(邮件地址或名称) | 收件人,支持:纯邮件地址、纯名称(含空格须加双引号)、`"名称<邮件地址>"` 格式 | `to:bob@company.com`、`to:"李 四"`、`to:"alice<a@b.com>"` | `to:李 四`(含空格未加引号) |
**组合查询说明:**
- 支持 `AND` / `OR` / `NOT` 逻辑运算符(大写)
- 括号用于分组:`(from:alice OR from:bob) AND folderId:2`
- 排除特定文件夹:`(NOT folderId:3) AND (NOT folderId:6)`
### message search 返回值说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `messages` | `List[]` | 邮件列表,每条包含邮件 ID 及元信息(不含正文) |
| `total` | `int32` | 符合条件的总邮件数 |
| `nextCursor` | `string` | 下一页游标,传入 `--cursor` 翻页;值为 `$` 表示已到达列表尾部 |
**翻页示例:**
```bash
# 第一页
dws mail message search --email user@company.com --query "folderId:2" --size 20 --format json
# 取返回中的 nextCursor,传入下一次请求(nextCursor="$" 时停止)
dws mail message search --email user@company.com --query "folderId:2" --size 20 --cursor <nextCursor> --format json
```
### 查看邮件完整内容
```
Usage:
dws mail message get [flags]
Example:
dws mail message get --email user@company.com --id <messageId>
Flags:
--email string 邮件所属邮箱地址 (必填)
--id string 邮件 ID (必填)
```
**返回字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `message` | `object` | 邮件完整信息,包含主题、发件人、收件人、正文、附件等 |
### 发送邮件
```
Usage:
dws mail message send [flags]
Example:
dws mail message send --from user@company.com --to colleague@company.com \
--subject "周报" --body "本周完成任务A和任务B"
Flags:
--body string 邮件正文,支持 Markdown 格式 (必填)
--cc string 抄送人列表
--from string 发件人邮箱 (必填),别名: --sender
--subject string 邮件标题 (必填)
--to string 收件人列表 (必填)
```
## 通用错误说明
以下错误适用于所有 mail 命令。
| 错误标识 | 含义 | 处理建议 |
|----------|------|----------|
| `domain.notFound` | 该用户的邮箱不是由钉钉邮箱托管,无法完成操作 | 确认邮箱是否已开通钉钉企业邮箱服务 |
## 意图判断
用户说"我的邮箱/邮箱地址" → `mailbox list`
用户说"找邮件/搜邮件/查邮件" → `message search`
用户说"看邮件/打开邮件/邮件内容" → 先 `message search` 获取 messageId,再 `message get`
用户说"发邮件/写邮件" → 先 `mailbox list` 获取发件地址,再 `message send`
用户说"给(某人名字)发邮件" → 先 `aisearch person` 获取 userId,再 `contact user get` 获取收件人邮箱,再 `message send`
## 严格禁止 (NEVER DO)
- 明确禁止猜测、假设、推断发件人和收件人邮箱
- 无法获取邮箱时,强引导ask_human,由用户确认,不要通过假设或其他方式继续执行
## 核心工作流
```bash
# 1. 查看可用邮箱 — 提取邮箱地址
dws mail mailbox list --format json
# 2. 搜索邮件 — 提取 messageId
dws mail message search --email user@company.com \
--query "subject:\"周报\" AND date>2025-06-01T00:00:00Z" --size 10 --format json
# 3. 查看邮件详情
dws mail message get --email user@company.com --id <messageId> --format json
# 4. 发送邮件
dws mail message send --from user@company.com --to colleague@company.com \
--subject "周报" --body "本周完成…" --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `mailbox list` | 邮箱地址 | message search/get/send 的 --email/--from |
| `message search` | `messageId` | message get 的 --id |
| `aisearch person` → `contact user get` | 用户邮箱 (orgAuthEmail) | message send 的 --to/--cc (跨产品) |
## 注意事项
- `mailbox list` 返回用户所有邮箱(含个人和企业),每条记录包含邮箱地址、账号类型、所属企业。选择邮箱时优先匹配用户当前所在企业的企业邮箱;若有多个可选,向用户确认后再操作
- `message search` 返回邮件 ID 和元信息(不含正文),需 `message get` 获取完整内容
- KQL 查询支持 AND/OR/NOT 组合,字段值含空格时需用双引号
- `--cc` 抄送人支持多人,逗号分隔
- 收件人邮箱获取:用户只知道同事名字时,先通过 `dws aisearch person --keyword "名字" --dimension name` 获取 userId,再 `dws contact user get --ids <userId>` 从返回中提取 orgAuthEmail 字段
+8 -19
View File
@@ -18,16 +18,6 @@ Flags:
--size string 每页数量 (默认 10)
```
### 搜索错误码
```
Usage:
dws devdoc article search-error [flags]
Example:
dws devdoc article search-error --keyword "403" --format json
Flags:
--keyword string 错误码或关键词 (必填)
```
---
## oa — 审批
@@ -67,17 +57,17 @@ Example:
### 查询待我处理的审批
```
Usage:
dws oa approval pending [flags]
dws oa approval list-pending [flags]
Example:
dws oa approval pending --format json
dws oa approval list-pending --format json
```
### 查询我发起的审批
```
Usage:
dws oa approval initiated [flags]
dws oa approval list-initiated [flags]
Example:
dws oa approval initiated --format json
dws oa approval list-initiated --format json
```
### 同意审批
@@ -109,13 +99,12 @@ Example:
## 意图判断
- 用户说"开发文档/API 文档/接口文档" → `devdoc article search`
- 用户说"API 报错/错误码" → `devdoc article search-error`
- 用户说"审批/请假/报销/出差" → `oa approval`
- 用户说"同意审批/批准" → `oa approval approve`
- 用户说"拒绝审批/驳回" → `oa approval reject`
- 用户说"撤销审批/撤回" → `oa approval revoke`
- 用户说"待我审批/我要审批的" → `oa approval pending` 或 `oa approval tasks`
- 用户说"我发起的审批" → `oa approval initiated`
- 用户说"待我审批/我要审批的" → `oa approval list-pending` 或 `oa approval tasks`
- 用户说"我发起的审批" → `oa approval list-initiated`
## 上下文传递表
@@ -124,5 +113,5 @@ Example:
| `devdoc article search` | 文档链接 | 直接展示给用户 |
| `oa approval list-forms` | processCode | detail / records 等 |
| `oa approval tasks` | taskId, instanceId | approve / reject |
| `oa approval pending` | instanceId | detail / approve / reject |
| `oa approval initiated` | instanceId | detail / revoke |
| `oa approval list-pending` | instanceId | detail / approve / reject |
| `oa approval list-initiated` | instanceId | detail / revoke |
+2
View File
@@ -1,5 +1,7 @@
# 工作台 (workbench) 命令参考
> ⚠️ **Draft**:以下命令在当前 CLI 运行时中尚未上线(`dws workbench --help` 回退到根帮助页面),文档仅供预览,待服务端注册后生效。
## 命令总览
### 查看所有工作台应用
+112 -6
View File
@@ -1,6 +1,8 @@
package cli_compat_test
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
@@ -146,7 +148,11 @@ func TestAttendanceShiftList_should_filter_empty_user_ids(t *testing.T) {
func TestAttendanceSummary_should_call_tool_with_user_and_date(t *testing.T) {
cap := setupTestDeps(t, "attendance")
root := buildRoot()
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{"user": "U001", "date": "2026-03-12 15:00:00"})
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "month",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -157,8 +163,9 @@ func TestAttendanceSummary_should_pass_user_and_date_flags(t *testing.T) {
cap := setupTestDeps(t, "attendance")
root := buildRoot()
_ = execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "month",
})
last := cap.last()
if last == nil {
@@ -200,10 +207,14 @@ func TestAttendanceSummary_should_error_when_user_missing(t *testing.T) {
}
}
func TestAttendanceSummary_should_pass_only_user_flag(t *testing.T) {
func TestAttendanceSummary_should_pass_user_id_through_vo(t *testing.T) {
cap := setupTestDeps(t, "attendance")
root := buildRoot()
_ = execCmd(t, root, []string{"attendance", "summary"}, map[string]string{"user": "U002", "date": "2026-03-12 15:00:00"})
_ = execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U002",
"date": "2026-03-12 15:00:00",
"stats-type": "month",
})
last := cap.last()
if last == nil {
t.Fatal("no call captured")
@@ -220,7 +231,11 @@ func TestAttendanceSummary_should_pass_only_user_flag(t *testing.T) {
func TestAttendanceSummary_should_use_dry_run_mode(t *testing.T) {
cap := setupTestDepsWithDryRun(t, "attendance")
root := buildRoot()
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{"user": "U001", "date": "2026-03-12 15:00:00"})
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "month",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -229,6 +244,97 @@ func TestAttendanceSummary_should_use_dry_run_mode(t *testing.T) {
}
}
// execSummaryDryRun runs attendance summary with --dry-run and returns the
// parsed QueryUserAttendVO map from the helper_invocation payload. This is
// necessary because the attendance handler is a custom helper (not a dynamic
// MCP-route), so the test framework's mcpCallCapture cannot intercept the
// call in non-dry-run mode.
func execSummaryDryRun(t *testing.T, flags map[string]string) map[string]any {
t.Helper()
root := buildRoot()
_ = setupTestDeps(t, "attendance")
cliArgs := []string{"-f", "json", "attendance", "summary", "--dry-run"}
for k, v := range flags {
if v != "" {
cliArgs = append(cliArgs, "--"+k, v)
}
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&bytes.Buffer{})
root.SetArgs(cliArgs)
if err := root.Execute(); err != nil {
t.Fatalf("unexpected CLI error: %v", err)
}
var payload struct {
Params map[string]any `json:"params"`
}
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("failed to parse dry-run output as JSON: %v\noutput: %s", err, out.String())
}
vo, ok := payload.Params["QueryUserAttendVO"].(map[string]any)
if !ok {
t.Fatalf("expected QueryUserAttendVO map in dry-run params, got %T: %v", payload.Params["QueryUserAttendVO"], payload.Params)
}
return vo
}
func TestAttendanceSummary_should_pass_stats_type_when_provided(t *testing.T) {
vo := execSummaryDryRun(t, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "month",
})
if vo["statsType"] != "month" {
t.Errorf("expected statsType=month in VO, got %v", vo["statsType"])
}
}
func TestAttendanceSummary_should_pass_stats_type_week(t *testing.T) {
vo := execSummaryDryRun(t, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "week",
})
if vo["statsType"] != "week" {
t.Errorf("expected statsType=week in VO, got %v", vo["statsType"])
}
}
func TestAttendanceSummary_should_error_when_stats_type_missing(t *testing.T) {
_ = setupTestDeps(t, "attendance")
root := buildRoot()
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
})
if err == nil {
t.Fatal("expected error when --stats-type is missing")
}
if !strings.Contains(err.Error(), "stats-type") {
t.Errorf("expected error message to mention stats-type, got: %v", err)
}
}
func TestAttendanceSummary_should_error_when_stats_type_invalid(t *testing.T) {
_ = setupTestDeps(t, "attendance")
root := buildRoot()
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "foobar",
})
if err == nil {
t.Fatal("expected error when --stats-type is neither week nor month")
}
if !strings.Contains(err.Error(), "week") || !strings.Contains(err.Error(), "month") {
t.Errorf("expected error message to mention week and month, got: %v", err)
}
}
// ── attendance rules ───────────────────────────────────────
func TestAttendanceRules_should_call_tool_with_date(t *testing.T) {
+1 -1
View File
@@ -258,7 +258,7 @@ func TestTodoTaskGet_should_call_query_todo_detail(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertToolName(t, cap, "query_todo_detail")
assertToolName(t, cap, "get_todo_detail")
}
func TestTodoTaskGet_should_pass_taskId(t *testing.T) {
+1
View File
@@ -23,6 +23,7 @@ var expectedPackagedSkillTargets = []string{
".kiro/skills/dws",
".trae/skills/dws",
".openclaw/skills/dws",
".hermes/skills/dws",
}
// seedDistArtifacts creates fake goreleaser output archives (empty tar.gz/zip
+8 -8
View File
@@ -776,13 +776,13 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
**contact_contact_dept_search_001**
- Prompt: 搜索部门,keyword 为 技术部
- Expected: `dws contact dept search --keyword 技术部 --format json`
- Flags: `--keyword` = `技术部`
- Expected: `dws contact dept search --query 技术部 --format json`
- Flags: `--query` = `技术部`
**contact_contact_dept_search_002**
- Prompt: 搜一下技术部的部门信息
- Expected: `dws contact dept search --keyword "技术部" --format json`
- Flags: `--keyword` = `技术部`
- Expected: `dws contact dept search --query "技术部" --format json`
- Flags: `--query` = `技术部`
#### `dws contact user get`
@@ -810,13 +810,13 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
**contact_contact_user_search_001**
- Prompt: 按关键词搜索用户,keyword 为 张三
- Expected: `dws contact user search --keyword 张三 --format json`
- Flags: `--keyword` = `张三`
- Expected: `dws contact user search --query 张三 --format json`
- Flags: `--query` = `张三`
**contact_contact_user_search_002**
- Prompt: 帮我找一下张三的联系方式
- Expected: `dws contact user search --keyword "张三" --format json`
- Flags: `--keyword` = `张三`
- Expected: `dws contact user search --query "张三" --format json`
- Flags: `--query` = `张三`
#### `dws contact user search-mobile`
+4 -4
View File
@@ -1014,7 +1014,7 @@
**contact_contact_dept_search_001** ✅ PASS
- Prompt: 搜索部门,keyword 为 技术部
- Expected: `dws contact dept search --keyword 技术部 --format json`
- Expected: `dws contact dept search --query 技术部 --format json`
- Skill Reference: references/products/contact.md
- Command path: PASS (contact dept search)
- Flags: PASS (1 flags validated)
@@ -1022,7 +1022,7 @@
**contact_contact_dept_search_002** ✅ PASS
- Prompt: 搜一下技术部的部门信息
- Expected: `dws contact dept search --keyword "技术部" --format json`
- Expected: `dws contact dept search --query "技术部" --format json`
- Skill Reference: references/products/contact.md
- Command path: PASS (contact dept search)
- Flags: PASS (1 flags validated)
@@ -1062,7 +1062,7 @@
**contact_contact_user_search_001** ✅ PASS
- Prompt: 按关键词搜索用户,keyword 为 张三
- Expected: `dws contact user search --keyword 张三 --format json`
- Expected: `dws contact user search --query 张三 --format json`
- Skill Reference: references/products/contact.md
- Command path: PASS (contact user search)
- Flags: PASS (1 flags validated)
@@ -1070,7 +1070,7 @@
**contact_contact_user_search_002** ✅ PASS
- Prompt: 帮我找一下张三的联系方式
- Expected: `dws contact user search --keyword "张三" --format json`
- Expected: `dws contact user search --query "张三" --format json`
- Skill Reference: references/products/contact.md
- Command path: PASS (contact user search)
- Flags: PASS (1 flags validated)
+16 -6
View File
@@ -10,6 +10,14 @@ import (
"testing"
)
// TestOpenSourceTreeOmitsEmbeddedHostMarkers scans source files for proprietary
// markers that must not leak into the public (open-source) tree.
//
// The scanner covers public docs as well as source code because OSS leakage is
// often introduced through documentation first. Only a small set of explicitly
// documented compatibility literals (see NOTE below) are allowed to remain in
// tree; everything else should trip the guard regardless of whether it appears
// in Go, shell, YAML, templates, or markdown.
func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) {
_, filename, _, ok := runtime.Caller(0)
if !ok {
@@ -17,6 +25,14 @@ func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) {
}
root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
// NOTE: REWIND_SESSION_ID / REWIND_REQUEST_ID / REWIND_MESSAGE_ID are
// intentionally NOT on this list. They are accepted by the CLI as
// optional backward-compatibility aliases for the primary DWS_* trace
// env names. Because they are a documented compatibility surface
// rather than an internal coupling to a specific host implementation,
// referring to these literals from source code and docs is allowed.
// Product names like "RewindDesktop" and other host-implementation
// specific symbols remain forbidden.
forbidden := []string{
"DWS_" + "BUILD_MODE",
"com.dingtalk.scenario." + "wukong",
@@ -27,12 +43,6 @@ func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) {
"EnablePrivate" + "UtilityCommands",
"UseExecutable" + "ConfigDir",
"DeleteExeRelative" + "TokenOnAuthErr",
"WriteToken" + "Marker",
"Token" + "Marker",
"tokenJSON" + "File",
"REWIND_" + "REQUEST_ID",
"REWIND_" + "SESSION_ID",
"REWIND_" + "MESSAGE_ID",
"MergeWukong" + "MCPHeaders",
"buildMode ==" + " \"real\"",
"wukong/" + "discovery",
+102
View File
@@ -0,0 +1,102 @@
// 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 unit_test
import (
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
)
// TestHostOwnsPATFlow_OnlySignal is the wire-level guard for the
// "custom authorization card" contract: the CLI switches to host-owned
// PAT mode iff the host injects DINGTALK_DWS_AGENTCODE. DINGTALK_AGENT /
// claw-type is purely a server-side routing tag and must NOT influence
// the decision, in either direction.
//
// Regression guard: several earlier drafts conflated the two signals,
// causing third-party Agent hosts that only set DINGTALK_DWS_AGENTCODE
// to still see a browser popup (CLI-owned fallback). This table locks
// in the decoupling.
func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
cases := []struct {
name string
agentCode string
agentEnv string
want bool
}{
{
name: "no signal → CLI-owned",
agentCode: "",
agentEnv: "",
want: false,
},
{
name: "agent code only → host-owned",
agentCode: "agt-cursor",
agentEnv: "",
want: true,
},
{
name: "agent code + DINGTALK_AGENT=default → host-owned",
agentCode: "agt-cursor",
agentEnv: "default",
want: true,
},
{
name: "agent code + DINGTALK_AGENT=business → host-owned",
agentCode: "agt-cursor",
agentEnv: "sales-copilot",
want: true,
},
{
name: "DINGTALK_AGENT=business, no agent code → CLI-owned",
agentCode: "",
agentEnv: "sales-copilot",
want: false,
},
{
name: "DINGTALK_AGENT=default, no agent code → CLI-owned",
agentCode: "",
agentEnv: "default",
want: false,
},
{
name: "whitespace-only agent code → CLI-owned",
agentCode: " ",
agentEnv: "sales-copilot",
want: false,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, tc.agentCode)
// DINGTALK_AGENT is set purely to demonstrate that it does NOT
// influence the host-owned decision. The literal env name is
// used here because the auth package no longer exports a
// DingTalkAgentEnv constant (it is not part of the PAT
// decision surface).
t.Setenv("DINGTALK_AGENT", tc.agentEnv)
if got := authpkg.HostOwnsPATFlow(); got != tc.want {
t.Fatalf(
"HostOwnsPATFlow() = %v, want %v (agentCode=%q, DINGTALK_AGENT=%q)",
got, tc.want, tc.agentCode, tc.agentEnv,
)
}
})
}
}
+268
View File
@@ -0,0 +1,268 @@
// 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 unit_test
import (
"encoding/json"
"strings"
"testing"
errpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// D5 Smoke A — PAT stderr JSON is a single-line, json.Unmarshal-able
// payload for every code in the frozen enum.
//
// This test is the CI-level guard for the wire invariant ("stderr JSON
// MUST be single-line and directly unmarshal-able"). It sits in the
// public `test/unit` tree — deliberately separate from
// internal/errors/pat_test.go — so the contract stays defended even
// after internal refactors that might move the JSON assembly code to
// another package or collapse helpers into it.
//
// Wire path exercised: ClassifyPatAuthCheck covers BOTH error families
// the SSOT freezes — patNoPermissionCodes (PAT_NO_PERMISSION and the
// three risk-tier variants) and patAuthRequiredCodes
// (PAT_SCOPE_AUTH_REQUIRED, AGENT_CODE_NOT_EXISTS). Using this single
// entry point keeps the test format-agnostic to future code-churn in
// ClassifyMCPResponseText's branching while still covering the full
// SSOT code enum.
//
// What we assert for every case:
// 1. Non-nil *PATError is returned with ExitCode == 4.
// 2. RawJSON has NO '\n' characters (single-line invariant).
// 3. RawJSON is directly json.Unmarshal-able into a map.
// 4. Parsed body has success=false and code matching the fixture.
// 5. Required downstream data shape is present (requiredScopes for
// PAT_*_NO_PERMISSION; missingScope for PAT_SCOPE_AUTH_REQUIRED).
func TestPATStderrJSON_SingleLineUnmarshalable(t *testing.T) {
t.Parallel()
type assertFn func(t *testing.T, parsed map[string]any)
requiredScopesPresent := func(t *testing.T, parsed map[string]any) {
t.Helper()
data, ok := parsed["data"].(map[string]any)
if !ok {
t.Fatalf("parsed.data missing or wrong type: %v", parsed["data"])
}
scopes, ok := data["requiredScopes"].([]any)
if !ok || len(scopes) == 0 {
t.Fatalf("parsed.data.requiredScopes missing or empty: %v", data["requiredScopes"])
}
for i, s := range scopes {
if _, ok := s.(string); !ok {
t.Fatalf("parsed.data.requiredScopes[%d] is not a string: %v", i, s)
}
}
}
missingScopePresent := func(t *testing.T, parsed map[string]any) {
t.Helper()
data, ok := parsed["data"].(map[string]any)
if !ok {
t.Fatalf("parsed.data missing or wrong type: %v", parsed["data"])
}
scope, ok := data["missingScope"].(string)
if !ok || scope == "" {
t.Fatalf("parsed.data.missingScope missing or not a string: %v", data["missingScope"])
}
}
agentCodeFieldPresent := func(t *testing.T, parsed map[string]any) {
t.Helper()
data, ok := parsed["data"].(map[string]any)
if !ok {
t.Fatalf("parsed.data missing or wrong type: %v", parsed["data"])
}
if _, ok := data["agentCode"].(string); !ok {
t.Fatalf("parsed.data.agentCode missing or not a string: %v", data["agentCode"])
}
}
cases := []struct {
name string
code string
body map[string]any
extract assertFn
}{
{
name: "PAT_NO_PERMISSION generic baseline",
code: "PAT_NO_PERMISSION",
body: map[string]any{
"success": false,
"code": "PAT_NO_PERMISSION",
"data": map[string]any{
"requiredScopes": []any{"aitable.record:read"},
"grantOptions": []any{"session", "permanent"},
"displayName": "阅读多维表记录",
"productName": "AI 表格",
},
},
extract: requiredScopesPresent,
},
{
name: "PAT_LOW_RISK_NO_PERMISSION",
code: "PAT_LOW_RISK_NO_PERMISSION",
body: map[string]any{
"success": false,
"code": "PAT_LOW_RISK_NO_PERMISSION",
"data": map[string]any{
"requiredScopes": []any{"aitable.record:read"},
"grantOptions": []any{"session", "permanent"},
"displayName": "阅读多维表记录",
"productName": "AI 表格",
},
},
extract: requiredScopesPresent,
},
{
name: "PAT_MEDIUM_RISK_NO_PERMISSION",
code: "PAT_MEDIUM_RISK_NO_PERMISSION",
body: map[string]any{
"success": false,
"code": "PAT_MEDIUM_RISK_NO_PERMISSION",
"data": map[string]any{
"requiredScopes": []any{"chat.group:write"},
"grantOptions": []any{"session", "permanent"},
"displayName": "发送群消息",
"productName": "群聊",
},
},
extract: requiredScopesPresent,
},
{
name: "PAT_HIGH_RISK_NO_PERMISSION",
code: "PAT_HIGH_RISK_NO_PERMISSION",
body: map[string]any{
"success": false,
"code": "PAT_HIGH_RISK_NO_PERMISSION",
"data": map[string]any{
"requiredScopes": []any{"finance.invoice:write"},
"grantOptions": []any{"once"},
"displayName": "开具发票",
"productName": "财务",
"authRequestId": "auth-req-xxxx",
},
},
extract: requiredScopesPresent,
},
{
name: "PAT_SCOPE_AUTH_REQUIRED",
code: "PAT_SCOPE_AUTH_REQUIRED",
body: map[string]any{
"success": false,
"code": "PAT_SCOPE_AUTH_REQUIRED",
"data": map[string]any{
"missingScope": "Contact.User.Read",
"hint": "run `dws auth login --scope Contact.User.Read`",
},
},
extract: missingScopePresent,
},
{
name: "AGENT_CODE_NOT_EXISTS",
code: "AGENT_CODE_NOT_EXISTS",
body: map[string]any{
"success": false,
"code": "AGENT_CODE_NOT_EXISTS",
"data": map[string]any{
"agentCode": "agt-unknown",
"hint": "请检查 DINGTALK_DWS_AGENTCODE / agent 注册",
},
},
extract: agentCodeFieldPresent,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
patErr := errpkg.ClassifyPatAuthCheck(tc.body)
if patErr == nil {
t.Fatalf("ClassifyPatAuthCheck(%s) returned nil; expected *PATError", tc.code)
}
if patErr.ExitCode() != errpkg.ExitCodePermission {
t.Fatalf("ExitCode() = %d, want %d (PAT exit_code MUST be 4)",
patErr.ExitCode(), errpkg.ExitCodePermission)
}
raw := patErr.RawStderr()
if raw == "" {
t.Fatalf("RawStderr() is empty; expected single-line JSON")
}
if strings.ContainsAny(raw, "\n\r") {
t.Fatalf("RawStderr() MUST be single-line (no \\n or \\r); got:\n%q", raw)
}
if strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t") {
t.Fatalf("RawStderr() MUST not have leading whitespace; got: %q", raw)
}
var parsed map[string]any
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
t.Fatalf("json.Unmarshal(RawStderr()) error = %v; raw=%q", err, raw)
}
if success, ok := parsed["success"].(bool); !ok || success {
t.Fatalf("parsed.success = %v, want false", parsed["success"])
}
gotCode, ok := parsed["code"].(string)
if !ok {
t.Fatalf("parsed.code missing or not a string: %v", parsed["code"])
}
if gotCode != tc.code {
t.Fatalf("parsed.code = %q, want %q", gotCode, tc.code)
}
tc.extract(t, parsed)
})
}
}
// TestPATStderrJSON_CodeEnumFrozen pins the exact code enum frozen for
// PAT-family errors (see patNoPermissionCodes / patAuthRequiredCodes in
// internal/errors/pat.go). If a future refactor accidentally renames a
// code (e.g. drops the risk-tier prefix) the classifier will stop
// producing a *PATError and this table-driven loop will fail with a
// clear message — preventing a silent break of the host integration
// contract.
func TestPATStderrJSON_CodeEnumFrozen(t *testing.T) {
t.Parallel()
frozen := []string{
"PAT_NO_PERMISSION",
"PAT_LOW_RISK_NO_PERMISSION",
"PAT_MEDIUM_RISK_NO_PERMISSION",
"PAT_HIGH_RISK_NO_PERMISSION",
"PAT_SCOPE_AUTH_REQUIRED",
"AGENT_CODE_NOT_EXISTS",
}
for _, code := range frozen {
code := code
t.Run(code, func(t *testing.T) {
t.Parallel()
body := map[string]any{
"success": false,
"code": code,
"data": map[string]any{"requiredScopes": []any{"stub.entity:read"}},
}
if patErr := errpkg.ClassifyPatAuthCheck(body); patErr == nil {
t.Fatalf("ClassifyPatAuthCheck(%q) returned nil — code was removed from the SSOT-frozen enum", code)
}
})
}
}