Compare commits

...
447 Commits
Author SHA1 Message Date
修雨 bba94c0092 docs(changelog): cut 1.0.46 (#542) 2026-07-01 16:45:06 +08:00
xuanandshangguanxuan.sgx fe4a79283c fix(pat): keep agent code grants aligned (#536)
Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-07-01 10:16:59 +08:00
Ariand修雨 3ee5f13c62 docs: condense Key Services table and document multi-org profiles (#527)
* docs: condense Key Services table and document multi-org profiles

The Key Services section listed a per-service command count and an exhaustive
subcommand token dump plus a long description, which had drifted out of date
and was hard to scan. Condense it (EN + zh) to a lark-cli-style
Service / Command / Capabilities table with a one-line capability per service,
pointing to docs/command-index.md for the full listing.

Also document the multi-organization (profile) capability, which had no README
coverage: a collapsible section placed right after "Custom App mode (CI/CD,
ISV integration)" in Getting Started, covering auth login adding a profile,
profile list / switch, the global --profile one-shot flag, and the agent-
orchestrated cross-org read pattern (writes stay on the current org). Mirrored
in README_zh.md.

CHANGELOG: add a [1.0.45] entry describing the full multi-profile feature
(login / profile management / --profile / backward-forward compatibility /
skill docs) plus the persistence hardening (locking, atomic writes, corruption
recovery, safe legacy mirror, no cross-org token fallback).

* docs(changelog): note --ai-tag default-on (#524) in [1.0.45]

---------

Co-authored-by: 修雨 <huyizhou.hyz@alibaba-inc.com>
2026-06-29 19:20:46 +08:00
e32fa1535c feat(auth): support multi-profile login (#500)
* feat(auth): support multi-profile login

* fix(auth): complete multi-org profile acceptance

* feat(auth): 完成多组织 profile 验收

* docs(auth): 补充多组织 Ralph 验收材料

* feat(auth): 支持 auth switch TUI 切换 profile

* feat(auth): logout 默认清理所有组织

* feat(auth): login 默认新增组织授权

* feat(profile): 使用 profile switch 切换组织

* docs(ralph): 更新 profile switch 验收材料

* fix(profile): 展示全部可切换组织

* feat(profile): support multi-org switch tui

* chore(install): add branch source installer

* fix(profile): keep global profile out of tool params

* feat(profile): support csv multi-profile runtime

* ci: add multi-profile e2e workflow

* ci: run multi-profile e2e on all branches

* docs: document multi-profile e2e ci gate

* docs: remove multi-profile test cases from pr

* ci: harden multi-profile e2e gates

* fix(auth): serialize profiles.json RMW and harden multi-profile persistence

Wrap all profiles.json read-modify-write paths (profile switch/use/remove,
status marking, token save, logout) in the existing dual-layer lock via a new
withProfilesLock helper. Split each writer into a public (locking) entry point
plus a lock-free *Locked variant so the non-reentrant lock is never re-acquired;
the refresh path (oauth_helpers) and the load-path legacy migration now call the
lock-free saver to avoid self-deadlock.

Also: write profiles.json and the token marker via per-write random temp names
(uuid) to stop concurrent writers from corrupting a fixed .tmp file; quarantine
an unparseable profiles.json and rebuild an empty config so the CLI can
self-heal instead of locking out auth reset/logout; make DeleteAllTokenData
proceed even if profiles.json cannot be read; and stop SyncLegacyTokenMirror
from deleting the legacy mirror on a transient keychain read error.

* fix(auth): do not fall back to a different org's legacy token slot

When no explicit --profile is given, LoadTokenDataForProfile resolves the
current/primary profile and reads its per-corp keychain slot. If that slot
read failed, the code silently fell through to the legacy single token slot,
which after any drift between the legacy mirror and the current profile could
belong to a different organization. The command would then run as the wrong
org with no indication to the user.

Reproduction (conceptual):
  - profiles.json currentProfile = corpA
  - corpA's keychain slot is unreadable, legacy single slot still holds corpB
  - any read command (no --profile) silently used corpB's token

Fix: when a profile is resolved but its slot read fails and no --profile was
given, only fall back to the legacy single slot when its CorpID matches the
resolved profile (same org); otherwise return the original error instead of
acting as a different organization. The no-profile legacy path (pre-migration
installs with no resolved profile) is unchanged.

Tests:
  - Covered by the existing internal/auth suite under go test -race; the
    same-org fallback preserves the legacy-mirror case while the cross-org
    case now surfaces the read error.

* feat(skill): document multi-org profile usage and always ship dws-shared

The skills had no guidance on the multi-profile capability, so an agent would
treat the CLI as single-org: when a lookup missed in the current org it would
give up or ask the user instead of searching other logged-in orgs. The multi
skill set also referenced a `dws-shared` prerequisite that was never actually
installed, and the only multi-org hints lived inline in three product skills.

This adds, in source only:
- A "multi-org / profile" section in the mono SKILL.md (concept, commands,
  cross-org rule, aggregation, safety guardrails) plus a decision-tree entry,
  trigger conditions, and a corrected logout danger-table row (logout removes
  all orgs by default; removing the primary silently re-elects a new primary,
  confirm before removing the primary).
- A standalone skills/multi/dingtalk-profile skill mirroring the same content.
- A new skills/multi/dws-shared skill that carries auth, global flags and the
  multi-org rule, so every product skill's PREREQUISITE resolves and all
  read/search skills inherit the cross-org behavior without per-skill edits.
- Cross-org fallback notes on dingtalk-aisearch / chat / contact.

To guarantee the prerequisite actually ships, multi-mode install now force-
includes dws-shared even when --skill / --exclude narrows the set (no-op when
the source has no dws-shared, preserving older layouts).

Tests:
  - internal/app: TestP1SharedAlwaysIncludedWithSkillFilter installs with
    `-s aitable` and asserts dws-shared still lands in the destination;
    TestP1SharedNoopWhenAbsent guards the older-layout no-op.
  - go test -race ./internal/auth/... ./internal/app/... passes.

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
Co-authored-by: qinze <audanye@gmail.com>
2026-06-29 18:27:25 +08:00
修雨 79b8eda3b6 feat(chat): default --ai-tag on so dws-sent messages carry the AI badge
Per req 83667761 (奕皓): messages sent through dws should carry the
「通过AI发送」badge by default, transparently flagging AI/CLI-sent messages.

- `--ai-tag` default flipped false → true on `chat message send` / `reply`, so
  no flag / `--ai-tag` / `--ai-tag=true` all attach clawType (open edition
  `openClaw`); only `--ai-tag=false` omits it (send as the user). The switch name
  is unchanged. reply honors the same default (no longer leaks the wukong
  clawType).
- skill chat.md: concise rule — default-on, pass `--ai-tag=false` to disable.
- tests: default now asserts clawType present; added an `--ai-tag=false` opt-out
  case.
2026-06-29 18:16:20 +08:00
修雨 be80790172 docs(changelog): add [1.0.44] — phantom guard, report contents-file, @file, sheet parity 2026-06-29 10:53:03 +08:00
修雨 2dbbca1ec9 docs(skill): align dws skill references with the real CLI
Every documented command / flag / example now matches `dws <svc> --help`:
- drop phantom commands (attendance class/group/vacation/..., contact label,
  ding message list/receiver-status) that map to undeployed tools.
- fix runtime-failure flags (mail --body→--content, doc/calendar/minutes
  pagination, chat send-by-bot @-flags, wiki member --users).
- route role/duty "who is responsible" queries to `aisearch person --dimension
  duty` instead of the removed `contact label`.
- realign the multi/dingtalk-report skill to entry submit / inbox list / outbox
  list.
2026-06-29 10:53:03 +08:00
修雨 b214c0a06c fix(sheet): wukong parity for range read/update
- accept scalar cells in range update
- flat values projection on read; null clears a cell
- add --hyperlinks flag to range update
2026-06-29 10:53:03 +08:00
修雨 d3087d170b feat(compat): native @file / --contents-file input for structured JSON flags
`dws report entry submit --contents-file <f>` (and `--contents -` stdin) silently
submitted `contents:[null]`. Root cause: the `--contents-file` flag had no
transform, so its value mapped to an unused param while `--contents` stayed empty.

- file_read_json transform + a build-time report hook resolve --contents-file /
  --contents - / @file natively in Go (priority: file > stdin > inline) and
  declare a contents / contents-file one-of group so a file-only invocation is
  no longer rejected at parse time.
- generalizes to @file / @- input for any structured JSON-array flag.
2026-06-29 10:53:03 +08:00
修雨 49637d982e feat(compat): hide phantom override commands from --help (tool-existence guard)
Override leaves whose backing MCP tool isn't actually deployed rendered in
`dws <svc> --help` but failed at invocation with "tool not found" (43 phantom
commands across 391 overrides; attendance declared 38, only 4 deployed).

BuildDynamicCommands now takes an existingTools oracle (CLI slug -> live tool
set from the tools/ cache). A leaf whose tool is missing from its resolved
server's set is marked Hidden; groups left childless collapse. Safety rails:
acts only when a server's tool set is KNOWN and non-empty (cold cache / overlay
/ plugin paths pass nil and no-op, never blanking the tree); serverOverride
leaves resolve against the target server; pipeline leaves are never hidden.

Adds scripts/dev/check-phantom-overrides.py as a publish-time gate, and
phantom_guard_test.go covering hide / cold-cache-keep / serverOverride / pipeline
/ empty-group-collapse.
2026-06-29 10:53:03 +08:00
修雨 4c5f1faeb1 feat: align open CLI with dws-wukong via cedar discovery version code (#509)
Switches the discovery version code bamboo -> cedar and aligns the open edition CLI with dws-wukong across communication (calendar book/acl/attendee, minutes tag, mail folder/template/contact, chat file upload, todo add-attachment, attendance transforms) and structured-office (aitable advperm/view/section/workflow/record, sheet/drive/wiki/doc) domains. Includes output-envelope parity, parse_bool/attendance_class_check_time transforms, --calendar-id support, CHANGELOG 1.0.43 and README command-index refresh. cedar config validated on pre and prod endpoints.
2026-06-26 21:49:38 +08:00
修雨 5833e71751 ci(mirror): localize README for the Gitee mirror (#513)
The Gitee mirror force-pushes main verbatim, so Chinese users saw a
README whose top install commands point at raw.githubusercontent.com
(hard to reach in China) and a coverage badge that fails to render
(the relative .github/badges/coverage.svg can't be served by Gitee —
gitee raw returns a signed, expiring URL with content-type text/plain).

Add a Gitee-only post-process step: build a gitee-main branch on top of
origin/main and rewrite README.md / README_zh.md before pushing —
(1) raw.githubusercontent.com/<repo>/main -> gitee.com/<repo>/raw/main
(2) the coverage badge -> a shields.io static badge whose percentage is
read from the repo's coverage.svg and colored by threshold.

GitHub's README is untouched; only the Gitee copy is rewritten. The
branch is rebuilt from origin/main every run, so it stays a clean
single-commit delta and never drifts.
2026-06-26 16:55:04 +08:00
修雨 0e690fbe4e fix(install): define $LatestUrl in install.ps1 so version resolution works (#512)
Resolve-LatestVersion referenced $LatestUrl (lines 185/197) but the
variable was never defined, so on the default GitHub path both
Invoke-WebRequest calls failed with a null Uri. With
$ErrorActionPreference = "Stop" the script then hit Write-Err and
exit 1 — closing freshly-launched PowerShell windows instantly
(the reported "闪退"). Every user on the default `latest` path was
affected; the Bash installer was unaffected because it inlines the URL.

Define $LatestUrl = "https://github.com/$Repo/releases/latest", mirroring
the Bash installer. Verified end-to-end with pwsh 7.5: the script now
resolves the latest tag, downloads, checksum-verifies and installs.
2026-06-26 16:27:31 +08:00
Ariand修雨 f7e8106a72 docs(devapp): add image-upload recipe + "discovering commands" to dingtalk-dev skill (#508)
* docs(devapp): add image-upload recipe + "discovering commands" to dingtalk-dev skill

The dingtalk-dev skill could set an app/robot icon via --icon-media-id but
never documented where a mediaId comes from: the dev command set has no
upload command, so a mediaId must be fetched from DingTalk's OpenAPI. Agents
had to guess the flow. The per-resource refs also lacked a uniform pointer to
self-discover commands and params, so they leaned on memory instead of --help
/ schema.

recipes.md: new "上传图片拿 mediaId" recipe — credentials get -> gettoken ->
OpenAPI /media/upload (multipart field `media`, type=image) -> robot config /
app update --icon-media-id -> read back. Includes a curl example and notes the
token TTL (~7200s, rate-limited) and a square-icon hint.

references/*.md: append a Chinese "发现命令" block to each of the 10 product
refs (app, credentials, webapp, permission, member, security, robot, version,
event, connect). Each block shows that group's own `--help` plus
`dws schema dev.app.<group>.<method>` (connect uses `dws schema dev.connect`),
mirroring SKILL.md's MUST DO.

Verified end-to-end on a real app (unifiedAppId via dws dev): uploaded a PNG
through /media/upload, set the robot icon with the returned mediaId, and
`robot get` reflected the new iconMediaId with robotStatus=ONLINE. All 10
`--help` targets and the 9 `dws schema dev.app.*` paths + `dws schema
dev.connect` resolve.

* docs(changelog): note dingtalk-dev mediaId recipe + command discovery (#508)

---------

Co-authored-by: 修雨 <47820304+PeterGuy326@users.noreply.github.com>
2026-06-25 23:46:48 +08:00
修雨 87a9b5b9be chore: drop dead fork dev-app cruft (feat/dws-devapp) (#507)
Two leftovers referenced the decommissioned wxianfeng fork branch
`feat/dws-devapp`, both now obsolete after the dev-app work landed on main
(v1.0.42) and the installers were repointed to DingTalk-Real-AI (#505):

  - .github/workflows/auto-dev-release.yml — triggered only on push to
    feat/dws-devapp (a branch that does not exist on this repo, so it never
    fires). Its purpose — auto-publishing fork dev-preview releases for
    install-devapp.sh — is gone now that install-devapp.sh pulls stable
    releases from DingTalk-Real-AI.
  - docs/devapp-yulan-command-routing.md — a 2026-06-05 draft design doc
    pinned to the fork branch and the pre-rename `devapp` command tree,
    superseded by the shipped `dws dev` command set and the rewritten
    docs/devapp-agent-install-guide.md.

After this, the repo has zero `wxianfeng` / `feat/dws-devapp` references.
2026-06-25 21:49:10 +08:00
修雨 97678e6441 fix(devapp): drop the fork — repoint dev installer + docs to DingTalk-Real-AI (#505)
* fix(devapp): point dev installer at DingTalk-Real-AI, drop the fork

install-devapp.sh / .ps1 and the robot quickstart still pulled the dev
binary + dingtalk-dev skill from wxianfeng/dingtalk-workspace-cli's
feat/dws-devapp fork branch. The dev-app work has since landed on main and
shipped in stable v1.0.42 under DingTalk-Real-AI, so the fork dependency is
obsolete.

  - DEVAPP_REPO default: wxianfeng/... → DingTalk-Real-AI/...
  - Bootstrap URLs in headers + quickstart: fork feat/dws-devapp → main.
  - Drop "preview/prerelease" wording — releases are now stable; the
    newest-release resolution still works either way.
  - Quickstart China note now points at the standard install.sh Gitee
    mirror (which carries dws dev in v1.0.42); install-devapp.sh pulls its
    binary from github.com, so a gitee-raw script alone would not help China.

Verified: releases?per_page=1 on DingTalk-Real-AI resolves v1.0.42 and the
darwin/​skills assets are present.

Note: docs/devapp-agent-install-guide.md is separately stale (describes the
old source-build flow + the pre-rename `dws devapp` command) and needs its
own rewrite — left out of this change.

* docs(devapp): rewrite agent install guide for binary install + `dws dev`

The guide was stale on two axes:
  - It described the old source-build flow (clone the fork branch + go/make,
    env vars DEVAPP_REPO_URL / DEVAPP_BRANCH / DEVAPP_SOURCE_DIR), but
    install-devapp.sh now downloads a pre-built binary (curl + tar, no
    git/go/make) from DingTalk-Real-AI.
  - Every command used the pre-rename `dws devapp ...`; the command is now
    `dws dev app ...`.

Rewrite against the real `dws dev` tree (verified from the binary):
  - install: DingTalk-Real-AI binary installer + correct env (DEVAPP_REPO /
    DEVAPP_VERSION / DWS_INSTALL_DIR / DWS_NO_SKILLS) + a China Gitee note.
  - skill name corrected to `dingtalk-dev`.
  - commands: `dws dev app {list,get,create,update,enable,disable,delete,
    credentials,permission,member,robot,security,version,webapp,event}` with
    real flags (--confirm-name, --scope-values, --user-ids, --redirect-urls,
    --version-id/--confirmed-sensitive), async robot create via submit/result,
    version publish gated by check-approval.
2026-06-25 21:04:38 +08:00
修雨 67090ae09f docs: add China (Gitee) install for the standalone Skills installer (#504)
The China-mirror section documented the main install.sh and the npm
package, but not the standalone install-skills.sh — even though that
script already honours DWS_GITEE_REPO and auto-falls back to Gitee when
GitHub is unreachable. The Skills install section only showed the GitHub
URL, so China users (and docs curated from this README) had no China
entry point for skills.

Add a "Skills only (Gitee mirror)" item to both China-mirror sections and
a pointer next to the Skills install command, in README.md and README_zh.md.
2026-06-25 21:04:34 +08:00
修雨 6f042f9167 fix(release): Gitee mirror reads real attach ids + dedups duplicate assets (#502)
The verify-replace mirror listed attachments via /releases/{id}, whose
"assets" array omits the attach id. DELETE /attach_files/{id} was therefore
called with an empty id and silently no-op'd, so a stale asset was never
removed — instead a second (correct) copy was uploaded. Gitee then serves the
OLDER attachment by name, so the stale darwin binaries kept winning and failed
install.sh's checksums.txt verification on macOS (国内 install broken).

Fix:
  - List attachments via the dedicated /attach_files endpoint, which DOES
    return the numeric id needed for deletion.
  - Treat duplicates: collect every attach id carrying a given name; when >1,
    delete them all and upload exactly one fresh, correct file. count==1 still
    does the byte-identical skip / stale-replace; count==0 uploads new.

Self-heals the existing v1.0.42 darwin duplicates on the next mirror run.
2026-06-25 20:10:34 +08:00
meng93 78dd4aaa4b Merge pull request #501 from DingTalk-Real-AI/fix/gitee-mirror-verify-replace
fix(release): Gitee mirror verifies content + replaces stale assets
2026-06-25 17:07:23 +08:00
修雨 088a4d67ae fix(release): Gitee mirror verifies content and replaces stale assets
The v1.0.42 Gitee release served darwin-amd64/arm64 binaries that did NOT
match checksums.txt (the macOS binaries are ad-hoc signed and differed
between the GitHub release and the earlier mirror run), so install.sh's
checksum verification failed for China macOS users. The previous skip-if-name-
present logic could not repair this — it skipped the stale assets.

sync-to-gitee.sh now verifies by content: for each artifact it compares the
sha256 of the asset already on Gitee against the local file (downloaded from
the GitHub release), and

  • skips it when byte-identical,
  • deletes + re-uploads it when present but stale,
  • uploads it when missing,

bringing the Gitee release into byte-for-byte agreement with the GitHub
release that checksums.txt describes. Re-running the Sync-release-to-gitee
workflow now self-heals a mismatched mirror.

bash -n + sha256 helper validated locally.
2026-06-25 16:54:46 +08:00
修雨 81f5245c8a Merge pull request #499 from DingTalk-Real-AI/fix/gitee-release-sync-idempotent
fix(release): idempotent Gitee mirror + standalone repair workflow
2026-06-25 16:33:20 +08:00
修雨 c4946c3eaf fix(release): make Gitee mirror idempotent + add standalone repair workflow
The v1.0.42 Release job hit timeout-minutes: 30 mid-upload while mirroring
release assets to Gitee, so the Gitee release ended up missing
dws-windows-arm64.zip and checksums.txt. Root cause + fixes:

- sync-to-gitee.sh now skips assets already attached to the Gitee release,
  so a re-run only uploads what is missing (instead of re-uploading every
  artifact and creating duplicates). It no longer fails when everything is
  already present.
- New workflow sync-release-to-gitee.yml (workflow_dispatch, version input)
  mirrors a published GitHub release's assets to Gitee on its own — it
  downloads the assets from the GitHub release and runs the idempotent sync,
  without running GoReleaser or touching the GitHub release (no outage). Use
  it to repair an incomplete Gitee mirror.
- Bump the Release job timeout 30 -> 60 so a full Gitee upload has room.

bash -n + YAML validated.
2026-06-25 16:21:34 +08:00
修雨 a0b956a780 Merge pull request #496 from wxianfeng/feat/dws-devapp
feat(devapp+connect): dev app management, robot connect, opencode server sessions
2026-06-25 15:19:39 +08:00
修雨 5e4d974039 Merge pull request #23 from wxianfeng/release/changelog-1.0.42
docs(changelog): cut 1.0.42
2026-06-25 14:47:24 +08:00
修雨 bb641c2098 docs(changelog): cut 1.0.42
Promote the [Unreleased] section to 1.0.42 (2026-06-25) ahead of封包:
robot connect custom channel, /new vs /clear session commands, and the
opencode 30s timeout fix. A fresh empty [Unreleased] stays on top.
2026-06-25 14:39:20 +08:00
修雨 7e1e93478b Merge pull request #22 from wxianfeng/docs/readme-connect-section
docs(readme): add DingTalk bot connect section with /new /clear
2026-06-25 14:33:06 +08:00
修雨 101e7be98d docs(readme): add a DingTalk bot connect section with /new /clear
README.md / README_zh.md had no connect coverage at all. Add a short
"connect a robot to your local AI" section above Key Services: the one-line
`dws dev connect` usage, the in-chat /new vs /clear session commands (with
the per-channel real-session-op behaviour, opencode DELETE /session), and a
pointer to docs/robot-quickstart.md. Docs only.
2026-06-25 14:21:32 +08:00
修雨 9545c1dde5 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	.github/badges/coverage.svg
2026-06-25 14:12:58 +08:00
修雨 81367b2861 Merge pull request #21 from wxianfeng/docs/changelog-connect-fixes
docs(connect): changelog + quickstart for /new /clear and the 30s timeout fix
2026-06-25 14:03:31 +08:00
修雨 97bab33c71 Merge pull request #494 from shangguanxuan633-lab/codex/dws-auth-login-recommend
feat(tui): apply DingTalk auth console styling
2026-06-25 14:02:21 +08:00
修雨 ef6d65087f docs(connect): changelog + quickstart for /new /clear and the 30s timeout fix
Document this release's two connect changes that landed via #19 and #20:
- CHANGELOG [Unreleased]: Added entry for /new vs /clear using each
  channel's real session op (opencode DELETE /session); Fixed entry for
  the opencode 30s client-timeout that aborted long agent turns.
- robot-quickstart.md: a 会话指令 section explaining /new (open fresh,
  keep old) vs /clear (real per-channel wipe; opencode deletes the
  server session).

Docs only; no code change.
2026-06-25 13:59:41 +08:00
shangguanxuan.sgx 30a024e615 fix(auth): keep chmod batch grants gated 2026-06-25 12:16:31 +08:00
shangguanxuan.sgx 00e5ce7367 fix(auth): skip recommend tui when no scopes remain 2026-06-25 11:23:08 +08:00
shangguanxuan.sgx 760c40dd71 Merge upstream main 2026-06-25 11:10:25 +08:00
修雨 aaa2d7d3be Merge pull request #20 from wxianfeng/feat/connect-session-commands
feat(connect): /new vs /clear use each channel's real session op
2026-06-25 11:01:11 +08:00
修雨 fbfcd69c2b feat(connect): make /new vs /clear use each channel's real session op
Before, /new and /clear were identical: both just dropped the local
conversationId->sessionId mapping. The agent-side session was never
disposed (opencode sessions leaked), and the two commands had no real
difference beyond their ack text.

Align them to each channel's real capability:
- /new (resetSession): drop the local mapping only; the old agent session
  is left intact and stays resumable where the agent supports it.
- /clear (new sessionClearer): actively dispose the current session via the
  agent's real delete primitive. opencode implements it with
  DELETE /session/:id (idempotent on 404). Channels whose agent exposes no
  delete in the mode DWS drives it (Codex app-server, Qoder stream, Claude
  exec) fall back to resetSession, so /clear behaves like /new there.

Tests pin both directions: /clear issues exactly one DELETE and drops the
mapping; /new (resetSession) issues zero DELETEs so the old session stays
resumable. Doc updated to describe the per-channel behavior.

go build ./... ok; go vet ok; go test ./internal/helpers ok.
2026-06-25 10:43:21 +08:00
修雨 fab9e5be52 Merge remote-tracking branch 'upstream/main' into feat/dws-devapp
# Conflicts:
#	CHANGELOG.md
2026-06-24 22:32:25 +08:00
修雨 ad056a8d83 fix(config): revert DefaultMCPBaseURL to production (mcp.dingtalk.com)
The fork's dev branch had the pre-release URL (pre-mcp.dingtalk.com)
hardcoded as the default MCP base, which would leak prepub config into
any release build. Revert to the upstream production default.

🤖 Generated with [Qoder][https://qoder.com]
2026-06-24 22:31:09 +08:00
修雨 0de6e46ef4 docs: cut 1.0.41 changelog + backfill 1.0.40 #488 (#495)
- 1.0.41: install auto-fallback to Gitee (#492) + CI auto-push mirror (#493)
- 1.0.40: add the missing #488 (skills embedded in binary) entry
2026-06-24 22:26:54 +08:00
修雨 9a8157a4cd ci(mirror): auto-push code to Gitee via HTTPS token (no SSH key) (#493)
Rewrite mirror-to-gitee.yml to push main + tags to the Gitee mirror using
HTTPS + GITEE_TOKEN (reusing the existing secret) instead of hub-mirror-action
+ an SSH key. Gated on GITEE_TOKEN; needs GITEE_USER + GITEE_REPO secrets.
Keeps Gitee code/raw in sync automatically on every push to main + tags, so
no more manual git push after each release.
2026-06-24 22:26:47 +08:00
修雨 b8e915edfb feat(install): auto-fallback to Gitee when GitHub unreachable (#492)
Probe GitHub Releases on startup; if it is unreachable (typical in mainland
China), automatically switch to the Gitee mirror — so a plain
`curl … | sh` (or .ps1 / install-skills.sh) works everywhere with no
DWS_GITEE_REPO env var. Explicit DWS_GITEE_REPO still wins; DWS_NO_FALLBACK=1
disables the probe; local source-checkout installs skip it.
2026-06-24 22:26:40 +08:00
xianfeng wang 5e254b4745 Merge pull request #17 from wxianfeng/sync/upstream-main-2026-06-24
sync: merge upstream/main into feat/dws-devapp (1.0.35–1.0.40)
2026-06-24 22:19:42 +08:00
修雨 29a8a14760 Merge pull request #19 from wxianfeng/fix/opencode-message-timeout
fix(connect): opencode message turns use per-turn ctx, not a 30s client cap
2026-06-24 21:54:20 +08:00
shangguanxuan.sgx 00bef0a809 fix(auth): skip login scope tui when grants are complete 2026-06-24 21:53:18 +08:00
修雨 3f5b2fa8d3 fix(connect): let opencode message turns use the per-turn ctx, not a 30s client cap
The shared opencode http.Client had a hardcoded 30s Timeout used for every
request including POST /session/{id}/message. Long agent turns (e.g. a web
research report) take longer than 30s, so the client aborted mid-flight with
"context deadline exceeded (Client.Timeout exceeded while awaiting headers)",
even though the per-turn budget (DWS_AGENT_TIMEOUT_MS, default 300s) was far
larger. Retrying hit the same wall.

Drop the client-level deadline (rely on the per-request ctx that doJSON already
wires via http.NewRequestWithContext) and bound only the /global/health probe
with a short 10s timeout so startup detection stays snappy.

Tests: pin Timeout==0 on the default client, and assert a slow reply succeeds
within the turn budget but is cut when the turn ctx is shorter than the reply.
2026-06-24 21:39:40 +08:00
shangguanxuan.sgx b0e7b58e95 feat(tui): apply DingTalk auth console styling 2026-06-24 21:32:49 +08:00
修雨 fe46cd4dc2 Merge pull request #18 from wxianfeng/codex/qoder-stream-sessions
[codex] keep Qoder sessions on stream-json
2026-06-24 20:57:47 +08:00
quanming c993086d9d feat(connect): keep qoder sessions on stream-json 2026-06-24 20:36:26 +08:00
修雨 f522a9c2c2 Merge upstream/main into feat/dws-devapp
Sync with DingTalk-Real-AI/main (1.0.35–1.0.40):
- 1.0.35–1.0.39: chat @-mentions, sheet export, doc strip unsafe chars,
  PAT batch grants, devdoc RAG, agent_code detection, AI-tag opt-in
- 1.0.40 (#486): China mirror via Gitee + npmmirror
- (#488) embed skills/ into binary
- (#464) strip duplicate H1 on JSONML doc-create

# Conflicts:
#	.github/workflows/release.yml
#	CHANGELOG.md
#	README.md
#	README_zh.md
#	docs/command-index.md
#	internal/app/runner.go
#	internal/app/runner_test.go
#	internal/auth/channel.go
#	internal/helpers/devdoc.go
#	internal/helpers/devdoc_test.go
#	internal/pat/chmod.go
#	internal/pat/chmod_test.go
#	internal/pat/pat.go
#	internal/transport/client.go
#	scripts/install-skills.sh
#	scripts/install.ps1
#	scripts/install.sh
#	skills/mono/references/products/devdoc.md
#	skills/mono/references/products/simple.md
#	skills/multi/dingtalk-devdoc/references/devdoc.md
#	skills/multi/dingtalk-skill/references/skill.md
#	test/cli_compat/devdoc_test.go
#	test/cli_compat/helpers_test.go
#	test/skill_tests.md
#	test/skill_tests_results.md
#	test/unit/pat_host_owned_signal_test.go
2026-06-24 20:15:53 +08:00
修雨 bd370ed0e9 fix(doc): strip duplicate title H1 on the JSONML doc-create path (#464)
PR #448 stripped a leading H1 matching --name on the markdown path of
`dws doc create`, but the JSONML path (--content-format jsonml) was never
covered. Rich documents — tables, callouts, styled blocks — go through
create_document + update_document(jsonml=...), which writes the body
verbatim, so a leading h1 whose text equals the document name renders the
title twice (the "two headings" effect the doc platform shows because it
already renders --name as the page title).

Add stripLeadingDuplicateTitleJSONML: parse the marshaled JSONML body,
skip an optional ["root", {}, ...] wrapper, and drop the first node when
it is an h1 whose concatenated leaf text equals --name (trimmed,
case-insensitive). Any parse failure or non-match leaves the body
untouched, so a valid write is never blocked. A stderr note mirrors the
markdown path so agents learn the convention.
2026-06-24 18:34:16 +08:00
修雨 9fa76f8598 docs: cut 1.0.40 changelog + fix gitee mirror gate (#489)
- CHANGELOG: 1.0.40 (China mirror via Gitee + npmmirror, #486)
- mirror-to-gitee.yml: gate on GITEE_PRIVATE_KEY so it skips cleanly when the
  SSH key is unset (code mirror handled by Gitee-side pull-mirror) instead of
  failing the run.
2026-06-24 17:55:35 +08:00
修雨 b175acb48f feat(skill): embed skills/ into the binary, default skill setup to it (#488)
go:embed the skills/ tree (mono + multi) into the binary and make
`dws skill setup` install from the embedded bundle by default. Upgrading
the binary now refreshes the installed skill, instead of silently reusing
a stale copy probed from the current working directory. An explicit
--source / DWS_SKILL_SOURCE still overrides for development.

Replants the change from #441 onto main: #441 was stacked on the
abandoned feat/chat-bot-provisioning-openclaw base (PRs #407/#397 closed),
which is why its CI was red. The embedded-bundle test asserted a
connect.md doc that only existed on that dead base; retargeted it to a
reference doc that exists on main.
2026-06-24 17:55:23 +08:00
wxianfeng d89649f9bb event search first 2026-06-24 17:53:08 +08:00
Aemeathandquanming f218a05ead [codex] use opencode server sessions for OpenCode connect
Co-authored-by: quanming <quanming@code.alibaba-inc.com>
2026-06-24 17:18:58 +08:00
修雨 ed1cbd6f06 feat(install): China mirror via Gitee + npmmirror (#486)
国内安装加速:DWS_GITEE_REPO 开关从 Gitee 解析下载脚本/二进制/skill(默认仍 GitHub,向后兼容);release.yml 发版后 CI 自动镜像 release 附件到 Gitee;README 加国内安装说明。Approved-by: wxianfeng.
2026-06-24 17:14:40 +08:00
修雨 62aef1cc96 Merge pull request #15 from wxianfeng/codex/connect-appserver-qoder-memory
[codex] align connect memory for Codex and Qoder
2026-06-24 15:16:39 +08:00
quanming fc5e4d0d8d fix(connect): align codex and qoder sessions 2026-06-24 15:09:50 +08:00
xianfeng wang 34248fabf3 Merge pull request #14 from PeterGuy326/feat/connect-channel-memory
feat(connect): slash commands + all-channel session memory + interactive onboarding
2026-06-24 13:43:25 +08:00
修雨 430d20f2ee ci(release): auto-sync release artifacts to China OSS mirror
Add scripts/release/sync-to-oss.sh and wire it into release.yml. After
the GitHub release, artifacts (binaries, checksums, dws-skills.zip) plus
the install scripts are pushed to an OSS bucket in the
<base>/<version>/<file> layout the install.sh DWS_RELEASE_BASE switch
expects, with a latest.txt pointer.

Gated on OSS_* repo secrets: skips cleanly when unset, so forks without
the secrets keep releasing to GitHub only.
2026-06-24 11:43:06 +08:00
修雨 ada4acc395 feat(connect): interactive onboarding to provision or pick a robot app
When `dev connect` runs with no credentials (no --robot-client-id/secret, no
--unified-app-id) in a real terminal, it now guides the user instead of failing:

- Ask "new app or existing app?".
- Existing: read a unified-app-id (reuses the credentials-get path) or an
  explicit clientId/clientSecret pair.
- New: read the app name, robot display name and description, submit the async
  robot-create task, then poll for the result to obtain credentials. This has a
  real side effect (it provisions a real robot app).

connect_onboarding.go keeps the flow channel-agnostic and io-injected so the
whole decision tree is unit-tested with a mock runner and scripted stdin (no
network, no real provisioning). Non-interactive invocations — scripts, daemons,
pipes, and --dry-run — are unchanged: they still require an explicit credential
flag, so nothing that worked before starts prompting.

Tests cover: existing/unified, existing/raw-creds, new-app submit+poll (asserts
the submit_robot_create_task / query_robot_create_result calls and params),
invalid choice, and a missing required field (no side-effecting call).

Note: the real provisioning path shares the same unverified credential field
names as devAppFetchCredentials (clientId/appKey, clientSecret/appSecret — see
its TODO(verify)); the live create→credentials flow is to be verified on the
pre-prod gateway with the -dws-devapp prerelease before relying on it.
2026-06-24 11:41:49 +08:00
修雨 8bebd77dfa feat(install): add DWS_RELEASE_BASE/DWS_LATEST_URL mirror switch for China
Parameterize the hardcoded GitHub Releases domain in install.sh,
install.ps1 and install-skills.sh so domestic users can point asset
downloads (binary tarball, checksums, skills zip) and latest-version
resolution at a China-accessible mirror (e.g. OSS+CDN / dingtalk domain).
Defaults stay on GitHub — fully backward compatible.
2026-06-24 11:30:12 +08:00
修雨 b096fa06db feat(connect): give opencode per-conversation memory via captured sessions
opencode persists its own sessions but, unlike the Claude family, will not let
the caller mint the session id up front: `opencode run` creates a session on the
first turn and only reports its id in the `--format json` event stream, then
continues it with `--session <id>`. So the connector now CAPTURES the id from
the output (like codex's thread id) instead of minting a UUID:

- connect_opencode.go: a dedicated opencodeForwarder that runs
  `opencode run --pure --format json`, accumulates reply text from `text`
  events, captures the top-level sessionID, and on the first turn persists the
  convID->sessionID map to <config>/connect/<clientId>/opencode-sessions.json
  (best-effort, restored on startup). The next message — and a connector
  restart — replays it with `--session <id>`. Implements streamingForwarder and
  sessionResetter, so /new and /clear work and streaming cards stream.
- forwarderForChannel routes the opencode channel to it.
- connectAgentOptionsPayload reports opencode memory as
  "per-conversation-captured"; --agent-memory help now lists codex/opencode too.

Verified end to end against the real opencode CLI: turn 1 captures the session
id, turn 2 with --session recalls a fact from turn 1. Unit tests cover capture +
resume (stub bin), store persistence across restart, and reset.

gemini also exposes --session-id/--resume but could not be verified end to end
here (no GEMINI_API_KEY) and its --resume id semantics are unconfirmed, so it is
left for a follow-up rather than wired blind. qoder's qodercli still has only
--resume (no addressable id) and cannot do per-conversation memory.
2026-06-24 10:57:25 +08:00
修雨 107f3eaf6e feat(connect): add /new /clear slash commands and persist codex session context
Two connector improvements for the linked-bot chat experience, modelled on
codex / OpenClaw connect:

1. Built-in slash commands. A user can type /new (aliases /start, /reset) to
   open a fresh session or /clear to wipe the current one, instead of the
   message being forwarded to the agent. The command set is fixed (never
   dynamically extended) so behaviour stays predictable. Matching requires the
   whole message to be the bare command, so a normal question that merely starts
   with a slash is forwarded untouched. Costs no agent turn / tokens.

   - connect_command.go: channel-agnostic parser + action table (+ tests).
   - sessionResetter optional interface; execForwarder (Claude family) and the
     codex app-server forwarder both implement it. The main loop intercepts a
     command right after the owner-decision interceptor and acks via webhook.

2. Persist codex per-conversation context. codexThreadSessions previously kept
   the convID->threadID map in memory only, so a codex connector restart lost
   every conversation's context — unlike the Claude-family convSessions store
   which already persisted. The thread identity is now the authoritative,
   mutex-guarded map persisted to <config>/connect/<clientId>/codex-threads.json
   (best-effort, atomic write), restored on startup. An empty clientId keeps it
   in memory only, preserving the original behaviour. This also removes a data
   race on threadID by moving it off the per-conversation state into the
   sessions store.
2026-06-24 10:30:04 +08:00
shangguanxuan.sgx df0c0f7545 revert(auth): restore pat authorization link output 2026-06-24 10:26:05 +08:00
qinzeandClaude Opus 4.8 91e67e2e45 docs(dev): event.md 事件码改用 event list 查询
去掉"以开放平台文档为准/dev doc search",改成"可订阅事件码通过 event list 查询
(返回 events[] 列出 eventCode/eventName/subscribed)"——event list 本就列出所有可订阅事件,更准。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:58:40 +08:00
shangguanxuan.sgx 75468dca1e fix(auth): hide raw pat url after browser open 2026-06-24 09:41:03 +08:00
shangguanxuan.sgx ef5c05a2e6 fix(auth): avoid duplicate pat authorization link output 2026-06-24 00:04:32 +08:00
shangguanxuan.sgx 1ac8636418 fix(auth): restore login authorization selectors 2026-06-23 23:09:11 +08:00
shangguanxuan.sgx 86086437d8 fix(auth): default login to recommended cli authorization 2026-06-23 21:57:17 +08:00
wxianfeng d432029d84 Merge branch 'feat/dws-devapp' of github.com:wxianfeng/dingtalk-workspace-cli into feat/dws-devapp 2026-06-23 19:45:19 +08:00
wxianfeng 029bfdd2ed version publish approval 2026-06-23 19:44:36 +08:00
修雨 8c0551f359 fix(install-devapp): handle GitHub API rate limit when resolving version
Unauthenticated curl is limited to 60 req/h, and once exhausted the API
returns 403. The previous `curl -fsSL ... | grep tag_name` swallowed the
403 silently, leaving DEVAPP_VERSION empty and surfacing a misleading
"No release found" error even though the fork has plenty of releases.

- Prefer `gh api` when available (authenticated, 5 000 req/h).
- Fall back to curl while capturing the HTTP status; on 403/429 fail
  with an actionable message instead of pretending no release exists.
2026-06-23 16:55:25 +08:00
xianfeng wang b839ee664e Merge pull request #12 from wxianfeng/fix/opencode-connect-pure
fix(connect): run opencode with --pure to neutralise operator plugins
2026-06-23 15:43:57 +08:00
修雨 c16c4aa59a fix(connect): run opencode with --pure to neutralise operator plugins
The opencode channel forwarded to `opencode run` without isolating the
operator's interactive environment. claudecode already neutralises via
`--setting-sources project --strict-mcp-config`; opencode had no equivalent.

When the operator runs a plugin suite that swaps the global default agent at
runtime (e.g. OhMyOpenCode's rotating Greek-codename agents), headless
`opencode run` crashes resolving a default agent that no longer exists
("default agent ... not found"), and the crash trace is sent back to DingTalk
as the bot reply. Verified by reproducing in a clean /tmp dir: plain
`opencode run` crashes, `opencode run --pure` replies normally.

Add --pure to the opencode argvTail so the bot answers in a clean,
plugin-free environment, consistent with the claudecode channel.
2026-06-23 15:07:19 +08:00
修雨 aecc0bc588 Merge pull request #11 from PeterGuy326/fix/mono-skill-dev-routing
fix(skill/mono): add dev product routing, prevent robot-creation mis-routing to chat
2026-06-23 14:06:18 +08:00
wxianfeng cfad3bbf4a name 2026-06-23 13:57:21 +08:00
修雨 40736e2ae1 fix(skill/mono): add dev product routing to prevent robot-creation mis-routing to chat
Without a dev entry in the product table and intent tree, agents reading the
mono skill routed 'create robot' / 'connect' requests to dws chat (because
'机器人' appeared in the chat product description), then landed on
dws chat bot search — wrong path.

Changes:
- skills/mono/SKILL.md: add dev row to product table; annotate chat row to
  scope it to IM-only; add dev intent-tree rule covering 创建机器人/建联/
  接入 agent/opencode/…; prepend dev-vs-chat disambiguation rule
- skills/mono/references/products/dev.md: new file — full command reference
  for dws dev app (lifecycle/robot build-号/credentials/permission/event/
  version) and dws dev connect (建联 flags, channel list, dry-run cli check,
  codex gotcha), sourced from dingtalk-dev multi-skill references
2026-06-23 13:53:53 +08:00
xianfeng wang 9aeb60da37 Merge pull request #10 from PeterGuy326/release/dws-devapp
feat(connect+devapp): 数字分身建联整套(已合 dev 重构)
2026-06-23 11:00:00 +08:00
修雨 d3fcd814f3 docs(quickstart): 安装改用勤泽的一键脚本;移除对 wxianfeng 无效的 release step
- quickstart 第一步从手动 curl 直链改为引用 scripts/install-devapp.sh /
  install-devapp.ps1。直链走 /releases/latest 在 wxianfeng 会 404(它只发
  -dev 预览版,无非 prerelease release);一键脚本走 GitHub API 取最新预览版,
  现在即可装到 v2.0.0.dev,且和团队安装入口统一
- 移除我此前加的 'Mark dws-devapp release as latest' step:其触发条件
  contains('-dws-devapp') 在 wxianfeng 永不成立(这边发 -dev tag),是死代码;
  改用一键脚本后也不再依赖 /releases/latest。release.yml 现与 base 完全一致
2026-06-23 10:45:15 +08:00
修雨 5ed69744cc Merge wxianfeng/feat/dws-devapp into release/dws-devapp
解决 devapp→dev 重构与 connect/数字分身工作的合并冲突,方向:全面对齐
wxianfeng 的 dev 命名空间,保留我方两处实质内容(动态端点 + connect 功能)。

冲突解决要点:
- release.yml:取 wxianfeng 更干净的 npm 守卫(repository_owner + 注释);
  保留我新增的「-dws-devapp 翻成非 prerelease+latest」步骤
- devapp.go / devapp_test.go / 路由文档:取 wxianfeng(新 MCP 工具名
  *_dev_app_*/extension、新增 event 工具、删 robot create、config 合 upsert)
- 端点:保留我方动态 devappMCPEndpoint()(网关 base 推导、生产默认),
  否则会把「建号对所有人打预发」的 bug 倒灌回来;同步修 helper_tool_fetcher.go
  与 direct_runtime_test.go 改用该函数 + wxianfeng 工具名 list_dev_app
- devapp_connect.go:connect 命令对齐 dev connect 命名,保留我方 --agent-cmd
  自研渠道示例(#37),建号引用修正为真实存在的 robot submit
- skills:跟随 wxianfeng 删除旧 dingtalk-devapp(已被重写的 dingtalk-dev 覆盖)
- robot-quickstart.md:命令全改 dws dev(建号两步 submit+result、dev connect),
  下载源改 wxianfeng,去掉写死版本号用 releases/latest

验证:go build ./... 通过;go vet 干净;internal/app、internal/helpers 单测全过
2026-06-23 10:21:29 +08:00
修雨 e5c9c91342 fix(release+docs): 让 -dws-devapp 可被'装最新',文档去掉写死版本号
问题:-dws-devapp 全系列被 goreleaser 标成 prerelease(semver 后缀触发),
GitHub 的 /releases/latest 跳过 prerelease,导致:
- 文档写死的 v1.0.53 每发一版就过期(现已落后到 v1.0.60)
- releases/latest/download 与 install.sh 的 latest 解析对 devapp 全失效

修复:
- release.yml 新增一步,仅当 tag 含 -dws-devapp 时把该 release 翻成
  非 prerelease 并标记 latest;goreleaser 仍保持 prerelease: auto,
  不影响 -dev.x 等其他预发版的语义
- robot-quickstart.md 三处下载/链接改用 releases/latest,去掉写死版本号,
  提示语软化为'能打印版本即成功'
2026-06-23 09:53:29 +08:00
qinzeandClaude Opus 4.8 0e59fedbbb ci: dev 分支 push 自动出预览 release(测试通过才发)
- 新增 auto-dev-release.yml: push feat/dws-devapp → 跑 build+test →
  通过则自增 dev 版本号、打 tag、自包含发 release(不靠 tag 触发 release.yml,
  绕开 GITHUB_TOKEN 不触发的限制); 不含 npm 步骤天然绿; concurrency 串行防撞号
- release.yml: npm publish 加 owner 条件, fork 跳过避免红叉

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:36:42 +08:00
修雨 c39378864d fix(release): goreleaser owner 用 GITHUB_REPOSITORY_OWNER 而非写死 fork
对齐 wxianfeng/feat/dws-devapp 已有写法:官方 CI 发到官方、fork CI 发到
fork,两边都对,避免合并时把动态 owner 顶回写死 PeterGuy326。
2026-06-23 09:32:52 +08:00
qinze 7cb33e970c Merge branch 'feat/dws-devapp' of https://github.com/wxianfeng/dingtalk-workspace-cli into feat/dws-devapp
# Conflicts:
#	skills/multi/dingtalk-dev/references/event.md
2026-06-23 09:19:27 +08:00
qinzeandClaude Opus 4.8 567cf163f1 docs(dev): event.md 澄清 connect/event 在 Stream 上的差异 + Stream 建立指向官方文档
- 加「connect 与事件订阅的关系」: connect 收机器人消息、event 只是配置订阅,
  dws 当前不消费事件; 两者唯一关联是先 connect 让长连在线 subscribe 才成功
- 「Stream 长连怎么建」改为命令视角 + 指向官方 SDK 文档(事件 RegisterAllEventHandler
  vs 机器人 RegisterChatBotCallbackRouter 别混), 不固化易过时的 SDK 代码

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:18:37 +08:00
wxianfeng d9ae15f9a6 Merge branch 'feat/dws-devapp' of github.com:wxianfeng/dingtalk-workspace-cli into feat/dws-devapp 2026-06-22 20:46:12 +08:00
wxianfeng 922745f318 event search 2026-06-22 20:45:14 +08:00
qinzeandClaude Opus 4.8 58dced0c8a feat(install): dev 安装支持 Windows(sh 加 Windows 分支 + 新增 ps1)
- install-devapp.sh: detect_os 加 MINGW/MSYS/CYGWIN, 按平台选 .zip/.tar.gz
  和 dws/dws.exe, 覆盖 mac/linux + Git Bash/WSL 下的 Windows
- 新增 install-devapp.ps1: 原生 Windows 用 irm|iex 一键装, 自包含下
  dev release 的 dws.exe + dingtalk-dev skill(含 opencode 目录)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:54:49 +08:00
qinzeandClaude Opus 4.8 3427b65da6 feat(install): install-devapp.sh skill 目录增加 opencode
opencode 全局 skill 目录是 ~/.config/opencode/skills(在 .config 下,
与其它 agent 的 ~/.xxx 不同),加入安装列表; gate 检查 ~/.config/opencode 存在才装。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:46:40 +08:00
qinzeandClaude Opus 4.8 9d38a3be54 fix(release): goreleaser owner 用 GITHUB_REPOSITORY_OWNER
写死 DingTalk-Real-AI 导致 fork CI 想往官方仓库发 release 被 403 拒绝。
改用 CI 自带的 GITHUB_REPOSITORY_OWNER, fork CI 发到 fork、官方 CI 发到官方。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:31:24 +08:00
qinzeandClaude Opus 4.8 1cda263e0e feat(install): install-devapp.sh 改为自包含下二进制安装(cli+skill)
- 从 fork 的 dev release 下预编译二进制 + dws-skills.zip, 只需 curl+tar,
  不再 clone 源码编译(去掉 go/make/git 依赖)
- 装 dingtalk-dev skill(不改名), 命令名对齐 dws dev
- 版本默认取 fork 最新 release(含 prerelease)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:24:56 +08:00
qinzeandClaude Opus 4.8 5d5884a0d7 fix(dev): 校准 dingtalk-dev skill 与真机一致 + 清理重复安装脚本
- skill 订正(以真机/schema 为准): app appStatus 字符串/permission 状态字段与
  apiPreview/credentials 去掉不存在的 showSecret/webapp 结构化空态/connect 去过时
  警告并补 flag/robot 去掉错误的 success 字段; version 用远端结构化审批字段
- 删除重复的 install-dev.sh, 保留 install-devapp.sh 并去掉会误拦用户的 go/make 检查

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:07:14 +08:00
玉澜 4a26f1ebd2 Merge remote-tracking branch 'origin/feat/dws-devapp' into feat/dws-devapp 2026-06-22 17:06:47 +08:00
玉澜 b65be61599 fix: limit devapp installer skills 2026-06-22 16:18:58 +08:00
玉澜 e193ba97f4 feat: add devapp install script 2026-06-22 16:08:51 +08:00
wxianfeng 787a40ffcb fix cr problem 2026-06-18 22:54:24 +08:00
quanming f69d9ed281 fix: align devapp member user ids 2026-06-18 19:37:17 +08:00
wxianfeng f2b2de89f4 fix cr problem 2026-06-18 18:34:12 +08:00
shangguanxuan.sgx 7915a5a4e1 docs: refresh batch auth release evidence 2026-06-18 17:18:46 +08:00
修雨 91f44a1efd docs: cut 1.0.39 changelog (#475 wukong-leak fix + #477 ai-tag opt-in) (#478) 2026-06-18 14:10:07 +08:00
shangguanxuan.sgx 6067906c55 fix(devdoc): fallback empty rag article search 2026-06-18 13:08:34 +08:00
shangguanxuan.sgx 493ca36e08 fix(pat): carry agentCode in batch auth args 2026-06-18 13:08:15 +08:00
修雨 1a7ba01e36 feat(chat): make AI-sent badge opt-in via --ai-tag (default off) (#477)
Previously every user-identity send/reply attached clawType=edition.ClawType()
unconditionally, so the IM server rendered an AI-sent badge ("通过AI发送" on the
open edition) under every message — surprising users by branding all their sends.

Make it opt-in: by default no clawType is attached (no badge). Passing --ai-tag
on 'chat message send' / 'chat message reply' attaches edition.ClawType() so the
badge shows (open=openClaw -> 通过AI发送, wukong overlay -> 悟空AI发送). Bot and
webhook sends remain untouched.

Follow-up to #475 (which fixed the hardcoded wukong leak, #474).
2026-06-18 12:22:19 +08:00
修雨 6310dcc39e feat(chat): tag user-identity message sends with edition clawType (#475)
Attach the clawType tool argument to every user-identity send path
(send_personal_message text/rich-media/reply and
send_direct_message_as_user) so the IM server can render the
"Send from AI" indicator on delivered messages. The value comes from a
new edition hook (Hooks.ClawTypeValue, exposed via edition.ClawType())
that falls back to DefaultOSSClawType ("openClaw"); overlays such as
wukong set their own identity to get their branded indicator.

Also fixes the reply command, which hardcoded clawType="wukong" and
made open-source replies carry the Wukong AI identity.

Bot sends intentionally stay untouched: they already render as bot
messages and must not carry the user-identity claw tag.
2026-06-18 10:53:52 +08:00
qinze 518b1cb631 Merge remote-tracking branch 'origin/feat/dws-devapp' into feat/dws-devapp
# Conflicts:
#	skills/multi/dingtalk-dev/references/version.md
2026-06-18 10:48:58 +08:00
玉澜 f29655e7e4 fix(devapp): let server assign app version 2026-06-18 10:32:52 +08:00
qinzeandClaude Opus 4.8 cc8a726b0a docs(dev): dingtalk-dev references 二次精简
- permission/robot/version 删 bash 命令示例堆叠(schema/--help 已覆盖),
  保留状态机/枚举语义/链路/gotcha
- 去掉所有 reference 的"概念锚点"自造词标签,保留指向概念地图的引语
- SKILL.md 生效模型去"(最重要)";connect 去加粗

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:09:04 +08:00
shangguanxuan.sgx f0552dd20e docs: record latest auth login prepub evidence 2026-06-18 02:53:00 +08:00
shangguanxuan.sgx a040b57be7 fix: stop auth login post-approval tui retry 2026-06-18 01:44:31 +08:00
shangguanxuan.sgx a2e8700beb docs: record caller-only recommend fallback 2026-06-18 00:48:01 +08:00
shangguanxuan.sgx 98804801c9 docs: record recommend empty-plan recheck 2026-06-17 23:17:40 +08:00
shangguanxuan.sgx 6946dd1e35 docs: record empty plan guard verification 2026-06-17 22:21:38 +08:00
shangguanxuan.sgx d09fca4b9b fix: guard empty login recommend all-scope plan 2026-06-17 22:16:58 +08:00
qinzeandClaude Opus 4.8 70d66daa5f feat(dev): schema 覆盖 dev doc + dingtalk-dev skill 精简
- dws schema 支持 dev.doc.search:fetcher 按 source 取(op-app 钉死 pre/
  devdoc 走运行时解析),给 doc search 叶子加 mcp-tool/mcp-source 注解
- dev doc search flag 对齐 MCP 参数名(--keyword/--size 为主,旧名隐藏兼容)
- dingtalk-dev SKILL.md:顶部加 MUST DO(每次先 --help/schema 查),核心规则
  瘦身、去重、去加粗/箭头符号
- references 精简:删参数表/flag 列表(schema/--help 已实时提供),只留状态机/
  覆盖语义/链路/gotcha;event 补"订阅前需先建联"业务规则;新增 recipes.md
  端到端链路;典型任务收成指针

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 21:56:11 +08:00
shangguanxuan.sgx 1926bd17b9 docs: update latest bff prepub instance 2026-06-17 21:48:12 +08:00
shangguanxuan.sgx 6a392e611c docs: record prepub deployment recheck 2026-06-17 21:25:45 +08:00
shangguanxuan.sgx 710844382e docs: record bff selected scopes hardening 2026-06-17 20:34:49 +08:00
shangguanxuan.sgx b1a88106e5 docs: record current cli recommend verification 2026-06-17 19:49:50 +08:00
shangguanxuan.sgx dcf132a7e3 fix: discover products before all-scope plan 2026-06-17 19:21:54 +08:00
shangguanxuan.sgx d2fd7e00b5 fix: skip tui for recommend login 2026-06-17 18:47:51 +08:00
shangguanxuan.sgx 7720e3ec9b docs: clarify post-login recommend tui 2026-06-17 17:56:36 +08:00
shangguanxuan.sgx 537719c677 fix: run recommend tui after login 2026-06-17 17:51:24 +08:00
qinzeandClaude Opus 4.8 51db546686 Merge origin/feat/dws-devapp into feat/dws-dev
合并远端团队的 MCP 对齐与 CR 修复,与本地 dev 命令重构 + schema 实时拉取 + flag 对齐 MCP 参数名集成。

冲突解决要点:
- 工具名常量:两边已收敛到同一组 op-app 真实工具名,取 dev 子树(ours)标识
- 保留 schema 能力:32 个 annotateDevAppTool 注解 + flag=kebab(MCP参数名)不变
- 吸收远端 CR 修复:normalizeDevAppServiceResult 解包 ServiceResult、
  remove_dev_app_permissions 结果扁平化、生命周期工具状态标记
- 旧 dingtalk-devapp 技能目录保持删除(已重命名为 dingtalk-dev)
- devdoc article search 统一 --page-size/pageSize,--size 留兼容别名

构建/vet/internal 测试全绿(28 包,0 失败)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:01:50 +08:00
shangguanxuan.sgx e9d50a659f docs: correct recommend tui chain 2026-06-17 16:54:17 +08:00
shangguanxuan.sgx 2264743e78 fix: model recommend login tui chain 2026-06-17 16:47:16 +08:00
qinzeandClaude Opus 4.8 6244222adf feat(dev): schema 实时拉取 MCP + flag 全面对齐 MCP 参数名
- 新增 dws schema dev.* 实时从 op-app tools/list 拉取参数(gws 扁平格式),
  description/type/required/default 全部 verbatim 来自服务端,零本地合成
- flag 全部改为 kebab(MCP 参数名),去掉别名,schema 展示与 CLI flag 完全一致,
  修掉 schema 显示假参数名的问题
- CLI 同步新 MCP 契约(工具名复数化、参数名 rename)
- dingtalk-dev skill 文档同步新 flag 名

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:43:19 +08:00
shangguanxuan.sgx a31a29e0ab docs: record recommend yes gate 2026-06-17 16:02:25 +08:00
shangguanxuan.sgx 3ec35e30e8 fix: respect yes for recommend login grants 2026-06-17 15:50:42 +08:00
shangguanxuan.sgx da08e84e80 docs: record mgdone tui evidence 2026-06-17 15:13:26 +08:00
shangguanxuan.sgx 5e1983b3df fix: align recommend tui interaction 2026-06-17 15:06:14 +08:00
wxianfeng 8984f7b8be fix cr problem 2026-06-17 12:13:28 +08:00
wxianfeng dd08db54d8 Merge branch 'feat/dws-devapp' of github.com:wxianfeng/dingtalk-workspace-cli into feat/dws-devapp 2026-06-17 10:03:55 +08:00
wxianfeng 591609fdee fix cr problem 2026-06-17 10:03:04 +08:00
quanming 6447bdd45f fix: align devapp member security app id flag 2026-06-17 09:53:31 +08:00
玉澜 2610c8ce87 Merge branch 'feat/dws-devapp' of https://github.com/wxianfeng/dingtalk-workspace-cli into feat/dws-devapp 2026-06-17 03:32:19 +08:00
玉澜 0e6cb46f58 fix: align devapp mcp compatibility 2026-06-17 03:29:46 +08:00
shangguanxuan.sgx a613728fbd docs: record recommend tui human flow 2026-06-17 01:03:04 +08:00
shangguanxuan.sgx 70b9f39715 fix: use human recommend flow by default 2026-06-17 00:58:27 +08:00
shangguanxuan.sgx d29aebeecd docs: record current batch auth gate status 2026-06-17 00:44:58 +08:00
wxianfeng 844059a77b fix cr problem 2026-06-17 00:43:19 +08:00
shangguanxuan.sgx 9ee8ea5524 fix: show recommend selector by default 2026-06-17 00:34:23 +08:00
shangguanxuan.sgx 42c71c9c54 docs: add frontend cdn verification 2026-06-17 00:08:51 +08:00
shangguanxuan.sgx 690d0788e3 docs: refresh batch auth release evidence 2026-06-17 00:05:44 +08:00
shangguanxuan.sgx 5a7c0748e3 feat: add recommend login product TUI 2026-06-16 23:19:24 +08:00
wxianfeng 410949cdf3 fix cr problem 2026-06-16 21:02:38 +08:00
修雨 f1a68f2424 docs: cut 1.0.38 changelog (#469) 2026-06-16 20:20:20 +08:00
patrickmen 497e4f87d8 feat: support to disable auto open browser (#365) 2026-06-16 20:08:29 +08:00
Jackjin a8d009aec8 fix(upgrade): honor --dry-run as preview-only instead of silently upgrading (#416)
dws upgrade registered no --dry-run flag and never read the global one,
so --dry-run fell through to runUpgrade and performed a real, irreversible
upgrade (download + replace binary). This contradicts the flag's documented
contract (预览操作内容,不实际执行).

Resolve the target release and platform asset (so a missing build / 'already
latest' is still reported), then render the planned 1-5 steps and return
before any side effect: no backup, no download, no replace. Advertise
--dry-run in the command examples.

Fixes #364
2026-06-16 20:08:25 +08:00
thisred 1f413fa322 fix: pipeline PollUntil case-insensitive comparison to fix sheet export hang (#462) 2026-06-16 20:08:17 +08:00
玉澜 0197dbc81a fix: align devapp DTO field names 2026-06-16 19:18:27 +08:00
shangguanxuan.sgx b85353e042 fix: emit single PAT authorization URL 2026-06-16 18:07:56 +08:00
修雨 ece91bfa3c feat(agent): accurate agent_code detection + per-channel agentId for stats (#467)
Tag each MCP request with which agent host is driving dws (agent_code) and a
per-(machine × agent_code) instance id, so usage can be sliced by channel and
instance in the data warehouse. Root cause it fixes: agent_code was only sent
when the host injected DINGTALK_DWS_AGENTCODE (~99.98% empty), so the gateway
logged none.

Detection ladder (every signature observed on a real host / official docs, not
guessed; unknown -> custom):
  T0 explicit DINGTALK_DWS_AGENTCODE
  T1 verified env signatures: claudecode (CLAUDECODE), codex (CODEX_SANDBOX),
     openclaw (OPENCLAW_BUNDLE_ROOT), hermes (HERMES_HOME)
  T2 VSCODE_BRAND value (covers the whole VS Code fork family)
  T3 macOS __CFBundleIdentifier map (qoder/cursor/vscode/workbuddy)
  T4 custom fallback

identity.json v2 (machineId + per-agent_code agents map), deterministic
dwsa_<base62> derivation, transparent v1 migration. Backward-compatible wiring:
x-dws-agent-id stays machine-level; new x-dws-agent-instance-id carries the
per-channel id; X-Cli-Version emitted so old/new clients are distinguishable.

Trust boundary (docs/agent-code.md): agent_code and the ids are self-reported
and spoofable — fit for statistics ONLY, never for auth/limit/billing.

Includes unit tests for every tier and the integration doc.
2026-06-16 17:55:29 +08:00
qinzeandClaude Opus 4.8 f740955423 chore(dev): skill 测试基建适配 dev 命令集
run_skill_tests / skill_e2e / skill_static / skill_tests 更新到 dev 技能。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:12:05 +08:00
qinzeandClaude Opus 4.8 166f665708 chore(dev): devapp→dev 重命名收尾(docs/mono/install/README/gitignore)
删除旧 devapp docs;mono 技能与 intent-guide 去 devapp;install 脚本改名
install-dev.sh;README 更新;.gitignore 放行 skill credentials.md 文档。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:11:50 +08:00
qinzeandClaude Opus 4.8 55f5e0c3d0 chore(dev): 删除旧 skills/multi/dingtalk-devapp
multi 模式旧 devapp 技能整组下线,已被 dingtalk-dev 替代。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:11:32 +08:00
qinzeandClaude Opus 4.8 f58b426381 chore(dev): 删除旧 skills/dingtalk-devapp(root 模式)
devapp 旧技能整组下线,已被 multi/dingtalk-dev 替代。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:11:32 +08:00
qinzeandClaude Opus 4.8 2640338803 docs(dev): dingtalk-dev skill reference (2/2) + dev 功能测试
webapp 产品文档;dev 命令集功能测试用例与报告(测试产物 results.jsonl 不入库)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:11:21 +08:00
qinzeandClaude Opus 4.8 dbcbb1de85 docs(dev): dingtalk-dev skill 概念结构 + 产品 reference (1/2)
SKILL.md 重构为「概念地图 + 核心规则/通用出参约定 + 典型任务 + 产品索引」;
reference 改为一命令组一文件;不在 agent 文档暴露内部 MCP tool 名。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:11:09 +08:00
qinzeandClaude Opus 4.8 63b6112fa2 test(dev): dev 命令集测试 + 建联/端点路由适配
helpers 单测、connect/card/stream 测试,app runner 与 direct_runtime devapp
端点路由。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:09:18 +08:00
qinzeandClaude Opus 4.8 eaeb9ac05d feat(dev): 开放平台 dev 命令集核心实现
dev 伞形命令(app/connect/doc)、应用全生命周期、权限/成员/安全/机器人/
版本/事件、cursor 透传分页、pretty 状态标注、connect 建联。MCP 工具名对齐
服务端实际注册名。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:09:06 +08:00
shangguanxuan.sgx e82ccd3496 docs: refresh batch auth verification state 2026-06-16 16:34:10 +08:00
修雨 b4c9db8807 feat(connect): custom channel for self-built/unsupported AI (issue #37)
Add a generic 'custom' channel plus an --agent-cmd flag so 'dws devapp
robot connect' can forward to ANY headless AI CLI (question appended as
the trailing arg, stdout used as the reply). Onboards tools that aren't
built-in (e.g. LobsterAI) or self-built agents with no code changes:
--agent-cmd forces channel=custom unless --channel is set explicitly,
and auto-detection falls back to custom when DWS_AGENT_CMD is present.

Also (issue #39): print a one-time hint at connect start when neither a
work dir nor a knowledge source is set, pointing at --agent-workdir /
--knowledge-* / --agent-model so the bot can match terminal answer
quality. Quickstart gains matching FAQ entries plus a note that step 3
(robot connect) produces no approval ticket (issue #19).
2026-06-16 15:04:04 +08:00
shangguanxuan.sgx aa76f01015 docs: record PAT app dev API build fix 2026-06-16 15:03:40 +08:00
shangguanxuan.sgx 931d7e59ef docs: refresh PAT prepub dependency status 2026-06-16 14:57:58 +08:00
shangguanxuan.sgx db2043c82b docs: record batch auth prepub status 2026-06-16 14:45:10 +08:00
wxianfeng c2f3653ec4 get_extension_robot_config 2026-06-16 14:40:17 +08:00
shangguanxuan.sgx 11dfd4ccf2 feat: add dws recommended batch auth flow 2026-06-16 14:09:15 +08:00
wxianfeng ef60d99b26 get_robot_config 2026-06-16 12:12:19 +08:00
wxianfeng 1b6e197426 fix conflict 2026-06-16 11:04:12 +08:00
wxianfeng 41b743de77 test 2026-06-16 11:02:42 +08:00
wxianfeng caf672699f tool name rename 2026-06-16 11:01:33 +08:00
修雨 ff33114b2c feat(doc): strip server-rejected unsafe chars on markdown write path (#465)
The doc write boundary only stripped a fixed dangerous-Unicode set, and only
on the JSONML path. C0 control characters (except tab/newline), DEL (0x7F),
and a few zero-width / line-separator codepoints still reached the server,
where RejectControlChars rejects them — so doc create/update failed on content
that pasted in such characters (common with LLM-generated or copy-pasted text).

- Rename stripDocDangerousUnicode -> stripDocInputUnsafe and extend it to drop
  C0 controls (except \t and \n) and DEL, matching apiclient.rejectDangerousChars.
- Add U+200D, U+2028, U+2029 to the dangerous-Unicode set so it covers the
  full server-rejected range.
- Apply the strip on the markdown write path (doc create/update) and the JSONML
  node path, not just the JSONML body.
- Add unit tests for stripDocInputUnsafe.

Ported from dws-wukong (feat: 增加输入安全字符过滤功能).
2026-06-16 10:56:53 +08:00
玉澜 67ac777657 Merge remote-tracking branch 'origin/feat/dws-devapp' into feat/dws-devapp 2026-06-16 10:13:57 +08:00
玉澜 c9ba0373c2 fix: align devapp cli tool mappings 2026-06-16 10:10:43 +08:00
quanming 4a19530fd0 fix: align devapp MCP tool names 2026-06-16 09:57:11 +08:00
修雨 7a28d97739 Merge pull request #38 from PeterGuy326/fix/issue-35-robot-create-async
fix(devapp): robot create routes to async submit/poll (issue #35)
2026-06-16 09:38:50 +08:00
修雨 d63f7e5836 fix(devapp): robot create routes to async submit/poll (issue #35)
The synchronous `create_dingtalk_robot` MCP action is broken server-side:
it dispatches to a connect-engine action template (detailId G-ACT-... /
versionId G-ACT-VER-...) that the engine can no longer find, returning
PARAM_ERROR / "找不到执行动作" for every caller (the orgId in the error is
hardcoded server-side, not the caller's). The async path
(submit_robot_create_task + query_robot_create_result) still works.

`dws devapp robot create` now submits the async task and polls the result
until a terminal status, returning the same agentId/clientId/robotCode/
clientSecret payload. User experience is unchanged — one command still
creates the robot. --dry-run previews the submit call.

- runDevAppRobotCreate orchestrates submit + interval-based polling
  (pending set WAITING/PROCESSING/RUNNING/PENDING/INIT/DOING, max 24 polls).
- Tests: create routing now expects submit; add poll-until-terminal and
  status-classification unit tests.
- Docs/skills updated to reflect the new routing.
2026-06-16 09:35:05 +08:00
玉澜 8aaf0fb6e8 fix: unwrap devapp service result responses 2026-06-16 08:03:47 +08:00
玉澜 0f3c4ccbdd fix: align devapp cli with MCP schema 2026-06-16 00:31:27 +08:00
修雨 1f21fdf7be Merge pull request #36 from PeterGuy326/feat/connect-kb-observability
feat(connect): knowledge-base observability + wider text formats (#32)
2026-06-15 22:30:49 +08:00
修雨 b2fd204e67 feat(connect): knowledge-base observability + wider text formats (issue #32)
"挂载的本地知识库不生效" was hard to diagnose: a dir of pdf/docx/json indexes
nothing, and a per-message retrieval miss was silent — both look identical to
the operator. Make the knowledge path observable and a bit more forgiving.

- index the .md/.txt FAMILY (.md/.markdown/.mdx/.txt/.text), not just .md/.txt
- loadKnowledgeBase logs how many files indexed vs skipped, and the empty-dir
  error now says only text is indexed (pdf/docx/json are not)
- augment logs per message whether it injected N chunks or missed (so a wrong
  knowledge dir / non-overlapping question is visible, not silent)

Tests: isKnowledgeTextExt, markdown-family indexing, clear no-text-files error.
2026-06-15 22:27:14 +08:00
wxianfeng 6f8c9173d3 Merge branch 'feat/dws-devapp' of github.com:wxianfeng/dingtalk-workspace-cli into feat/dws-devapp 2026-06-15 21:00:31 +08:00
shangguanxuan.sgx 1921e5e37e feat(devdoc): support cursor pagination 2026-06-15 20:41:53 +08:00
wxianfeng 6480c035fa Fix review problem 2026-06-15 20:29:50 +08:00
修雨 1c88dd6a0d Merge pull request #34 from PeterGuy326/feat/connect-twin-confirm-policy
feat(connect): confirm_policy — manual / auto / remember
2026-06-15 17:30:25 +08:00
修雨 6ce4a635b2 style: gofmt connect_approval.go (struct tag alignment) 2026-06-15 17:26:59 +08:00
修雨 ee2fa735b2 feat(connect): confirm_policy — manual / auto / remember for others' requests
RoleConfig.confirm_policy was parsed but never acted on. Wire the three
strategies for how OTHERS' action requests are confirmed (the owner's own
requests always auto-run regardless):

- manual (default): ask the owner every time
- auto: run without asking — full trust — still fully audited
- remember: ask once per action verb, then reuse that decision (approve →
  auto-run same verb; reject → auto-decline same verb)

- gateDecision(requester, verb) returns auto / ask / reject
- ApprovalRequest.Verb carries the action verb (remember key + audit)
- handleOwnerDecision caches the decision per verb under the remember policy
- applyRoleConfig fills opts.ConfirmPolicy; launchConnector wires + logs it

Tests: auto runs without asking, manual asks, remember reuses the 1st decision.
2026-06-15 17:22:45 +08:00
修雨 a9df88654d Merge pull request #33 from PeterGuy326/feat/connect-twin-scope-enforce
feat(connect): enforce role capability scopes — keep a twin in its lane
2026-06-15 17:16:11 +08:00
修雨 e58e805b17 feat(connect): enforce role capability scopes — keep a twin in its lane
RoleConfig.allowed_scopes was parsed but never enforced. Now an action whose
product is outside the role's allowlist is refused before it reaches the gate,
so an HR-assistant role can't be made to touch code/drive.

- applyRoleConfig fills opts.RoleScopes from the role's allowed_scopes
- the orchestrator gains allowedScopes + scopeAllows(product); empty = allow all
- handleReply refuses an out-of-scope action up front and tells the requester
  it is out of the twin's lane (a capability boundary, not an approval, so safe
  to surface); nothing reaches the gate or the owner
- launchConnector wires opts.RoleScopes into both orchestrators and logs the
  boundary at startup

Tests: out-of-scope refused (not gated), in-scope proceeds, empty = allow all.
2026-06-15 17:13:02 +08:00
修雨 d1d6d9e74c Merge pull request #31 from PeterGuy326/feat/connect-twin-auto-retry
feat(connect): auto-retry deferred backlog — no manual 重试 needed
2026-06-15 17:07:36 +08:00
修雨 b75f07547f feat(connect): auto-retry deferred backlog — no manual retry needed
A deferred backlog (queued while the connector's dws login was not yet the
bot owner) now drains by itself once the owner's identity is back, instead of
requiring the owner to manually reply 重试.

- startAutoRetry runs a 2-minute background ticker (text mode) that calls
  autoFlushDeferred until the connector context is cancelled
- autoFlushDeferred replays the backlog but messages the owner ONLY when
  something actually completed — a tick while the identity is still wrong is
  silent (no spam); each completed requester is still notified
- flushDeferred (manual 重试) and the auto path share flushDeferredOnce
- the deferred notice now tells the owner it will auto-recover after login,
  with 重试 as an optional 'do it now'

Tests: auto-retry stays silent while stuck, drains + notifies on recovery.
2026-06-15 17:04:28 +08:00
修雨 ccb4927c48 Merge pull request #30 from PeterGuy326/feat/connect-twin-more-actions
feat(connect): twin can also create calendar events and docs
2026-06-15 16:59:37 +08:00
修雨 1a2454a5ab feat(connect): twin can also create calendar events and docs, not just todos
The digital twin previously only knew one action (todo.create). Add two more
owner-scoped write actions so it can act as a real stand-in:

- calendar.create → calendar/create_calendar_event (summary + start/end)
- doc.create → doc/create_document (name)

Both flow through the same gate: detected via [[ACTION:...]] marker, classified
as write (gated), approved/deferred/audited identically. The agent system hint
now lists all three action markers. A shared firstNonEmpty arg helper.

Tests: toPlannedAction maps each verb to the right product/tool, missing
required args degrade to not-an-action, and every action classifies as write.
2026-06-15 16:55:37 +08:00
shangguanxuan.sgx 0a8de62041 Revert "feat(skills): add self-contained dingtalk-open-platform doc skill"
This reverts commit 2c51774150.
2026-06-15 16:47:35 +08:00
修雨 d6f44143ad Merge pull request #29 from PeterGuy326/feat/connect-twin-polish
fix(connect): retry audit-sheet throttles; flush reports completed items
2026-06-15 16:37:12 +08:00
修雨 4d831e5054 fix(connect): retry audit-sheet throttles; flush reports completed items
Two polish items surfaced during the digital-twin smoke test:

- audit-sheet append now retries transient throttles (THREADPOOL_BUSY /
  timeout / 429) up to 3x with backoff, instead of silently dropping the
  audit row on the first busy response. isTransientSheetErr keeps permanent
  errors (bad node id, permission) non-retried.
- flushDeferred reports WHAT it completed (a numbered per-item list), not just
  a count. When the owner is also the requester — the common self-request
  case — this list is their only completion receipt, since notifyRequester
  skips owner==requester to avoid double-messaging.

Tests: isTransientSheetErr classification, flush per-item completion report.
2026-06-15 16:32:41 +08:00
shangguanxuan.sgxandCursor 2c51774150 feat(skills): add self-contained dingtalk-open-platform doc skill
DingTalk open-platform documentation skill as an agent execution manual
(discover -> auth -> call -> verify -> recover), fully self-contained
(no cross-skill references; dws is only a reference tool), with a
built-in doc index llm.md of verified open.dingtalk.com/document links.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 15:38:21 +08:00
修雨 a10169344f Merge pull request #28 from PeterGuy326/feat/connect-deferred-execution
feat(connect): deferred execution — hold + retry when the twin can't act yet
2026-06-15 15:09:19 +08:00
修雨 82d625603f feat(connect): deferred execution — don't lose a task when the twin can't act yet
When an approved action can't execute right now (most often the connector's
dws login is not the bot owner, so an owner-scoped action like creating a
personal todo fails), the request is no longer dropped with an opaque error.
Instead it is held and retried after the owner recovers.

- new 'deferred' state: an approved request whose execution didn't complete;
  persisted like any other, distinct from terminal 'failed'
- on execution failure (auto-approve path and owner-approve path), mark
  deferred instead of failed, and privately DM the owner what went wrong and
  how to recover (log dws in as the bot owner, then reply 「重试」). The
  requester is NOT told it failed — their task is held, not lost
- 「重试/恢复」owner keyword flushes the backlog: re-execute each deferred
  request oldest-first; on success mark executed and send the requester the
  outcome, on failure keep it deferred
- markDeferred / allDeferred on the gate; deferred counts as approved() so the
  retry can still mark it executed
- tests: deferred-hold-then-retry end to end, isRetryWord

This is the graceful-degradation half of the digital twin: a request survives
an owner-offline / wrong-identity window and completes once the owner is back.
2026-06-15 15:05:45 +08:00
修雨 11780ae3b0 Merge pull request #27 from PeterGuy326/feat/connect-twin-private-approval-audit
feat(connect): private owner approval, self auto-approve, online-sheet audit
2026-06-15 14:50:50 +08:00
修雨 79bab762bf feat(connect): private owner approval, self auto-approve, online-sheet audit
Builds on the text-approval gate with the digital-twin model the owner asked
for: the approval stays strictly between the bot and its owner, and every
action is auditable in DingTalk.

- private owner DM: an action request is no longer posted into the requester's
  conversation; the bot DMs the OWNER privately (robot oToMessages/batchSend,
  reusing the bot's own credentials) and the requester sees nothing until the
  result. Decision matching moved from per-conversation to latestPending, since
  the owner replies from their own 1:1 chat, not the request conversation.
- owner self auto-approve: when the requester IS the owner, asking is the
  authorization — the action runs immediately with no second confirmation, but
  is still recorded (auto_approved=true, decided_by=owner) for audit.
- online-sheet audit: optional --audit-sheet / --audit-sheet-tab appends one
  row per terminal action (time, summary, requester, state, decided_by, auto,
  error, id) to a DingTalk online sheet via sheet append_rows, reviewable in
  DingTalk on any device — beyond the local approvals JSON.
- aiCardClient.sendOTOText: proactive 1:1 robot text send primitive.
- requester gets the final outcome (executed/failed/rejected) by 1:1 message;
  the approval process itself is never exposed to them.

Tests: parseDecisionWord, latestPending, private-DM approve/reject,
owner self auto-approve, audit terminal records, classifier wiring intact.
2026-06-15 14:47:02 +08:00
修雨 e840cac3cc Merge pull request #26 from PeterGuy326/feat/connect-text-approval
feat(connect): text approval fallback — owner confirms in chat, no card needed
2026-06-14 11:08:40 +08:00
修雨 45b4f088af feat(connect): text approval fallback — owner confirms in chat, no card needed
The confirmation gate previously REQUIRED a hand-built interactive card
template (per app, manual button param binding in the card console) — and
without one the gate silently turned off. That manual step is too clunky to
be the default. Add a text-approval mode so the digital twin works with zero
card-platform setup: when --owner-user-id is set but no --approval-card-
template, the bot posts a plain confirmation and the owner replies 「同意」/
「拒绝」 in chat to decide.

- newTextApprovalOrchestrator: gate enabled on owner alone (no card sender)
- text mode is async, NOT blocking: handleReply posts the prompt and returns;
  the owner's decision arrives as an ordinary inbound message captured by
  handleOwnerDecision (blocking on Await would deadlock the per-conversation
  worker that must also process that reply)
- gate.pendingForConv maps an owner reply (carries only the conversation) back
  to its request; parseDecisionWord matches a WHOLE-message keyword so an
  embedded 「同意」 never accidentally approves
- only the configured owner can decide; non-owner / non-keyword pass through
- devapp_connect: no card template now falls back to text mode instead of
  disabling the gate
- existing card flow refactored into handleReplyCard, unchanged behavior
- tests: parseDecisionWord, pendingForConv, text-mode approve/reject,
  non-owner rejected, non-decision pass-through
2026-06-14 11:05:13 +08:00
修雨 b28ca6364e Merge pull request #25 from PeterGuy326/feat/connect-role-runtime
feat(connect): wire role config into the connector runtime
2026-06-14 10:45:10 +08:00
修雨 8b39dac5db feat(connect): wire role config into the connector runtime
Sprint 2 item 2: a digital-employee role config (connect_role.go) was a
defined-but-unused schema. Wire it into the connector so a single
--role-config <file> supplies the bot's owner, persona and knowledge
sources instead of scattering them across flags.

- add --role-config / DWS_ROLE_CONFIG; load + validate in launchConnector
  with a client_id-must-match-this-bot guard (one role == one bot)
- applyRoleConfig merges role -> options with explicit-flag-wins semantics
  (a flag/env always overrides the role; knowledge sources are additive)
- owner feeds the approval gate; persona is prepended to every forwarded
  prompt; role knowledge sources load and merge into the same retriever
- AllowedScopes / ConfirmPolicy are parsed and logged but not yet enforced
  (scope gating + auto/remember strategies are a later slice; no dead opts
  fields carried for them)
- tests: applyRoleConfig flag-wins / fill-empty / nil-role

Scope enforcement and confirm-policy behavior intentionally deferred.
2026-06-14 10:41:37 +08:00
修雨 7ecfe85696 Merge pull request #24 from PeterGuy326/feat/connect-gate-classifier
feat(connect): gate engages via read/write classifier, not every marker
2026-06-14 10:27:16 +08:00
修雨 9af7f9a034 feat(connect): gate engages via read/write classifier, not every marker
Sprint 2 item 1: wire the dws command read/write classifier into the
digital-twin approval gate. Previously any detected [[ACTION:...]] marker
was unconditionally routed through the owner-confirmation gate. Now the
gate consults ClassifyDwsCommand on the planned command: a read-class
action bypasses the gate and replies normally, while write — and Unknown,
per the CmdClass safety contract — keep the owner sign-off requirement.

- add classifyPlannedAction bridging plannedAction -> CmdClass (classifies
  on LegacyPath, falls back to product + RPC tool verb)
- handleReply skips Submit/card/Await for a read-class action
- tests: classifyPlannedAction table + handleReply read-class bypass
  (override-driven, exercises the wiring end to end)
2026-06-14 10:22:01 +08:00
修雨 c50494775c Merge pull request #23 from PeterGuy326/feat/connect-cmd-classify
feat(connect): classify dws commands as read vs write for approval gating
2026-06-14 00:12:04 +08:00
修雨 1fe4842525 Merge pull request #22 from PeterGuy326/feat/connect-role-config
feat(connect): role config schema for per-role digital personas
2026-06-14 00:11:57 +08:00
修雨 5708e1c2d2 Merge pull request #21 from PeterGuy326/feat/connect-approval-gate
feat(connect): approval gate — execution requests need owner sign-off before running
2026-06-14 00:11:51 +08:00
修雨 d43ec228aa feat(connect): digital-twin confirmation gate vertical slice
Add the M2 confirmation gate (digital twin): an action request from a
group member is not executed directly. The bot sends an [Approve]/[Reject]
card to the owner over the existing Stream long-connection and only runs the
planned command after the owner approves.

- connect_approval.go: gate engine. ApprovalRequest model, Pending to
  Approved/Rejected to Executed/Failed state machine, thread-safe store with
  crash-safe atomic on-disk persistence (~/.dws/connect/<clientId>/approvals,
  0600, restart-recoverable), Submit/Decide/Await/Get API, and the simplified
  [[ACTION:...]] marker detection for execution-class requests.
- connect_approval_card.go: approvalCardSender boundary (DI-fakeable), the
  card-callback decoder (approval id + decision from action params, OutTrackId
  fallback), the orchestrator driving Submit to card to Await to execute to
  reply, and the real DingTalk interactive-card sender.
- connect_stream.go / devapp_connect.go: minimal wiring. Register the card
  callback router, decorate the prompt for action detection, route a marked
  reply through the gate, --owner-user-id / --approval-card-template flags.

Tests: state machine + persistence/recovery, card-action decode, reject path
(no execution), and a no-network end-to-end approve chain. gofmt/vet/build/
test all green.
2026-06-13 23:34:37 +08:00
修雨 fb335fb403 feat(connect): add dws command read/write classifier
Add ClassifyDwsCommand, a standalone heuristic classifier that labels a
dws command (by path segments) as read / write / unknown. It is the
signal a connector confirmation gate consumes to decide whether a robot
may run a command directly (read-only) or must first ask the principal
to approve it (write / mutating).

- Read/write verb tables calibrated against real dws cobra Use: verbs
  scanned from the repo (list/get/search/status/export/download vs
  create/update/delete/send/done/upload/recall/mkdir/chmod ...).
- Segments scanned right-to-left so the leaf action verb dominates a
  container/placeholder segment; falls back toward the root when the
  leaf is unrecognised (e.g. a trailing id).
- Compound leaf verbs normalised by their leading token before the
  first '-'/'_' (send-by-bot, list-forms, batch-update, add-bot ...).
- Override mechanism: per-call map (ClassifyDwsCommandWith) and a
  process-wide table (SetCmdClassOverride), keyed by single verb or
  full space-joined path, full-path winning. Overrides beat heuristics.
- Unknown is returned losslessly; the CmdClass doc comment documents the
  safety contract that callers must treat Unknown conservatively as
  write (require confirmation), never auto-allow.

This is the classifier component only; wiring it into the confirmation
gate is a separate change.
2026-06-13 23:25:04 +08:00
修雨 fd61526707 feat(connect): add digital-employee role config schema and loader
Introduce internal/helpers/connect_role.go: a standalone configuration
component for the one-role-per-bot digital-employee model. Each role binds
to a single bot via client_id and carries its persona, knowledge sources,
allowed capability scopes, owner userId and confirmation policy.

- RoleConfig struct (YAML) with manual/auto/remember confirm policy enum.
- LoadRoleConfig: read + parse + validate with clear, non-panicking errors
  (required name/client_id/owner_user_id, policy enum, source grammar).
- knowledge_sources reuse the existing parseKnowledgeSource grammar so the
  schema stays in lockstep with --knowledge-source (path / wiki: / doc:).
- LoadRoleConfigs: index roles by client_id for multi-bot deployments,
  rejecting duplicate client_id.
- RoleConfigExample documents a full role and is asserted in tests.

Schema only; no runtime wiring.
2026-06-13 23:21:16 +08:00
修雨 c858241b68 Merge pull request #20 from PeterGuy326/fix/forwarder-test-arity
fix(connect): repair forwarderForChannel test arity after #15/#16 merge
2026-06-13 23:07:09 +08:00
修雨 b826440995 fix(connect): update provider-model test for forwarderForChannel clientID arg
PR #15's connect_provider_model_test.go calls forwarderForChannel with the
old 2-arg signature; PR #16 added a clientID parameter. Both branches passed
in isolation but the merged release broke 'go test ./internal/helpers/'.
Pass an empty clientID (the test exercises model-arg injection, unrelated to
session persistence).
2026-06-13 23:03:12 +08:00
修雨 25f69b867e Merge pull request #18 from PeterGuy326/feat/connect-knowledge-wiki
feat(connect): mount DingTalk wiki space as a knowledge source
2026-06-13 22:58:23 +08:00
修雨 172060b868 Merge pull request #17 from PeterGuy326/feat/connect-daemon
feat(connect): daemonize the connector with crash-restart, status and stop
2026-06-13 22:58:17 +08:00
修雨 dd0990691e Merge pull request #16 from PeterGuy326/feat/connect-session-persist
feat(connect): persist conversation sessions across connector restarts
2026-06-13 22:58:11 +08:00
修雨 02bc7ce880 Merge pull request #15 from PeterGuy326/fix/reply-card-422-fallback
fix(connect): stop echoing provider 422 into chat; drop haiku pin for custom providers
2026-06-13 22:58:04 +08:00
修雨 4d476ed317 style: gofmt wiki knowledge source sources 2026-06-13 22:23:58 +08:00
修雨 ff89ffed69 style: gofmt connect_daemon sources 2026-06-13 22:23:48 +08:00
修雨 65ab1a3076 feat(connect): add daemon mode with crash-restart, status and stop
Turn `dws devapp robot connect` into a 7x24 background service.

- `connect --daemon` re-execs dws into a detached supervisor (POSIX setsid),
  prints pid + log path, and exits. Windows is unsupported (stub errors out).
- A supervisor process keeps a worker connector alive, restarting it with
  exponential backoff (1s..60s cap, 10 consecutive fast-failure ceiling).
- `connect status` reports running/stale/not-running, pid, uptime and log path.
- `connect stop` sends SIGTERM (worker releases the single-instance lock and
  Stream connection), escalates to SIGKILL on a timeout, and cleans the pid file.
- stdout/stderr are archived to ~/.dws/connect/<clientId>/daemon.log via the
  existing size-based rotator (new additive logging.NewRotatingFile).
- docs/connect-daemon-service.md ships launchd and systemd templates for
  boot-time auto-start.

The single-instance lock (connect_lock.go) is reused unchanged. The foreground
forwarding/session/knowledge logic is untouched; devapp_connect.go only gains
the --daemon flag, two hidden re-exec flags, and the status/stop subcommands.
2026-06-13 21:44:47 +08:00
修雨 cf277f3c8f feat(connect): add wiki/doc knowledge source for the Q&A connector
Extend the connector's knowledge retrieval (previously local .md/.txt only)
to a DingTalk knowledge base via --knowledge-source wiki:<spaceId> (and
doc:<docId>). On startup the connector pulls the space by reusing the existing
`doc` tools that back `dws doc list` / `dws doc read` (list_nodes +
get_document_content) through the shared executor.Runner -- no new DingTalk API
call is implemented. Pulled documents are dumped as markdown into a local cache
(~/.dws/connect/<clientId>/knowledge/wiki-<spaceId>/) and indexed by the same
chunker/retriever as the local-directory source.

Refresh on startup; on a pull failure (network/permission) fall back to a stale
cache if present, otherwise an empty knowledge base -- the connection is never
blocked. The legacy --knowledge-dir source is untouched and coexists.

Node listing uses a shallow id lookup so a response wrapper object is not
mistaken for a single node (which would drop sibling nodes).
2026-06-13 21:43:48 +08:00
修雨 59100407dc feat(connect): persist conversation sessions across connector restarts
convSessions held the DingTalk-conversation to agent-session map purely
in memory, so a connector restart dropped every chat's multi-turn
context - the core weakness of an always-on digital employee.

Persist the map to ~/.dws/connect/<clientId>/sessions.json (scoped per
clientId so multiple bots on one machine stay isolated), reusing the
existing config dir, 0600 FilePerm and lock-id sanitizer conventions:

- newConvSessions restores the map on startup; a missing or corrupt
  file degrades to an empty map with a warning, never a panic or a
  blocked connection.
- args/reset persist after every mutation, under the existing mutex so
  the snapshot is race-free, via a temp-file + rename atomic write.
- saves are best-effort: a write failure only logs a warning and never
  blocks message handling.
- an empty path (clientId missing or --agent-memory off) keeps the map
  in memory only, preserving the original behaviour exactly.

forwarderForChannel now takes clientId to derive the per-bot store path.
Adds unit tests for reload, corrupt-file degradation, in-memory mode and
concurrent access (race-clean).
2026-06-13 21:39:45 +08:00
修雨 10de987e81 fix(connect): stop echoing provider 422 into chat; drop haiku pin for custom providers
issue #14: the "422" seen in the group was not from the DingTalk card API but
from the claude subprocess's model provider, echoed back as the reply.

Root cause:
- claudecode's spec pins --model claude-haiku-4-5-20251001 in both argvTail and
  streamArgvTail.
- claudeUserSettingsEnv (issue #10) injects the user's ANTHROPIC_BASE_URL /
  ANTHROPIC_AUTH_TOKEN into the claude subprocess. With a third-party provider
  relay that has no mapping for that exact haiku pin, the provider returns
  "API Error: 422 ...".
- execForwarder.forward used cmd.Output() and returned any non-empty stdout as
  the answer without checking err, so the raw 422 text was forwarded to the chat
  on every message.

Fix (A + B):
A. In forwarderForChannel, when a custom provider base URL is in effect
   (injected via Claude settings or already in the process env) and the user did
   not pick a model (--agent-model / DWS_AGENT_MODEL), strip the built-in model
   pin so the provider's default model applies. Official-login users keep the
   pin; an explicit --agent-model still wins.
B. In forward and forwardStream, treat stdout that starts with "API Error:"
   (or a process error) as a failure: return a short, actionable Chinese hint
   ("...请用 --agent-model <model> 指定模型后重连.") instead of echoing the raw
   backend error. Normal replies are unaffected.

Adds unit tests for the haiku-drop wiring, the API-Error guard, and the new
helpers.
2026-06-13 14:43:24 +08:00
修雨 c36fcaaf70 Merge pull request #13 from PeterGuy326/fix/quickstart-paste-safe-placeholders
docs(quickstart): paste-safe placeholders for the connect example
2026-06-12 18:05:55 +08:00
修雨 0cf97cea55 Merge pull request #12 from PeterGuy326/fix/skill-setup-embedded-source
fix(skill): embed skill sources in binary + cover qoder dirs in upgrade refresh
2026-06-12 18:05:49 +08:00
修雨 47354b918a docs(quickstart): make the connect example paste-safe in zsh
The step-3 example used <第二步的clientId> style placeholders; pasting
them verbatim makes zsh treat the angle brackets as redirections and
fail with "parse error near \n" before the CLI even runs (issue
PeterGuy326#11). Use realistic dummy values plus an explicit replace-me
note instead, and add a FAQ entry for anyone still hitting the error
from the old wording. Also bump install snippets to v1.0.53-dws-devapp.
2026-06-12 18:00:56 +08:00
修雨 df1e33442b fix(skill): embed skill sources in the binary and cover qoder dirs in upgrade refresh
Two distribution defects left installed skills stale (issue PeterGuy326#8):

1. skill setup resolved its source from cwd / exe-adjacent checkouts, so
   an upgraded binary happily re-installed whatever stale checkout it was
   run from, and agents kept routing 'create robot' into the chat dead end.
   The skills/mono and skills/multi trees are now embedded (go:embed) and
   setup defaults to that copy, materialized into ~/.dws/skills/<mode> —
   upgrading the binary upgrades what setup installs. --source and
   DWS_SKILL_SOURCE stay as explicit overrides, and an override that has
   no skill root errors out instead of silently falling back.

2. dws upgrade's skill refresh (knownSkillDirs) skipped ~/.qoder and
   ~/.qoderwork, so Qoder/QoderWork hosts never got refreshed routing.
   Added both to the dir table and its seven mirrors (npm install.js,
   install.sh/ps1, install-skills.sh, homebrew template, package test,
   verify-package-managers.sh).

setup-side detection and --target qoder/qoderwork already work on this
branch; this closes the remaining upgrade-side and source-side gaps.
2026-06-12 17:57:48 +08:00
修雨 a7ca1e1a5b fix(connect): pass user-level Claude settings env through to the claudecode channel
The claudecode channel spawns claude with --setting-sources project to
keep the bot persona neutral, but that also drops the user-level
settings.json env block where third-party provider tools (cc-switch
etc.) store ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN. claude then
fell back to the official login and replied "Not logged in - Please
run /login" for anyone on a relayed model.

Re-expose that env block as process environment for the spawned CLI
(both one-shot and streaming paths) via the channel envFn hook.
Variables already exported by the operator are not overridden, and
user hooks/plugins still stay out of replies.

Fixes PeterGuy326#10.
2026-06-12 16:14:50 +08:00
修雨 211a06c05a fix(devapp): resolve op-app MCP endpoint from gateway base URL
The devapp endpoint was hardcoded to the pre gateway, so robot
create/list hit the pre environment for everyone. Derive it from the
configured MCP gateway base URL instead: production by default, pre
when ~/.dws/mcp_url points at the pre gateway.

Also bump quickstart doc to v1.0.51-dws-devapp and add a FAQ entry for
the production-side developer-identity check.
2026-06-12 14:54:05 +08:00
修雨 bb52a505dc docs: add customer-facing robot quickstart guide (install, create, connect, add to group) 2026-06-12 14:20:26 +08:00
修雨 f899f4f6b6 chore(release): target PeterGuy326 fork for goreleaser, guard npm publish to official repo 2026-06-12 12:04:22 +08:00
wxianfeng 3adeffb09d event subscribe 2026-06-12 11:33:55 +08:00
xianfeng wang 435bf3151c Merge pull request #9 from PeterGuy326/feat/connect-qa-hardening
feat(connect): Q&A-bot hardening — picture messages, knowledge retrieval, allowlist/rate-limit, per-chat FIFO, single-instance lock
2026-06-12 10:02:47 +08:00
修雨 1c90edb92a fix(connect): drain buffered app-server frames before honoring read EOF
The codex app-server read loop reports EOF before closing the message
channel, so when the process exits right after its final frame, both
channels are ready and select picks one at random — runTurn could drop
an already-received turn/completed and fail with "stream ended before
turn/completed: EOF" (the CI flake on this PR and #8). Drain buffered
frames first; reproduced at ~7%/300 runs before, 0/300 after.
2026-06-12 09:42:51 +08:00
修雨 36b89a04b1 Merge remote-tracking branch 'wxianfeng/feat/dws-devapp' into HEAD
# Conflicts:
#	internal/helpers/connect_stream.go
2026-06-12 09:18:10 +08:00
修雨 cdd8414891 docs: cut 1.0.37 changelog (#457) 2026-06-11 19:53:26 +08:00
Aemeath 9d3980f90a Merge pull request #8 from wxianfeng/codex/codex-channel-lab
[codex] add Codex app-server robot bridge
2026-06-11 19:49:11 +08:00
quanming ae5ef70bb8 fix: harden codex app-server client concurrency 2026-06-11 19:27:50 +08:00
xuanandshangguanxuan.sgx 2a82d07311 fix(pat): support batch agentCode and guarded grants (#455)
* fix(pat): carry agentCode in batch auth args

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

* fix(pat): require yes for batch grants

* test(pat): cover cli authorization matrix

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

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-06-11 19:21:25 +08:00
玉澜 89c3aba2ed docs(devapp): capture publish workflow edge cases 2026-06-11 13:09:27 +08:00
修雨 c151756168 feat(connect): Q&A-bot hardening for the stream bridge
Five operational gaps that bite when a connector-backed bot answers
questions in real groups:

- Single-instance lock: one connector per robot per machine (pid file,
  stale-lock takeover). Duplicate Stream connections on one clientId get
  messages load-balanced between them, so the bot answers intermittently
  (verified live).
- Access policy: --allowed-users / --allowed-groups allowlists plus a
  per-sender rate limit (--user-rate-limit, default 20/min). Every
  message is an LLM call; without this anyone who can reach the bot can
  spend on its behalf.
- Per-conversation FIFO queue: same-chat messages run in arrival order
  (follow-ups need the previous turn's --resume session; parallel CLIs
  racing one session corrupt it); different chats stay parallel.
- Picture messages: resolve the callback downloadCode via robot
  messageFiles/download to a local temp file and hand the path to the
  agent CLI — error screenshots are the top Q&A inbound and were
  previously dropped silently. The claudecode persona now permits
  reading attached files.
- --knowledge-dir: lightweight local retrieval (markdown chunking,
  latin-token + CJK-bigram scoring) prepends top-k matching chunks to
  each question while the agent keeps running from the clean scratch
  dir, so replies stay inside DingTalk's AI-assistant response window.

All knobs have env mirrors (DWS_ALLOWED_USERS / DWS_ALLOWED_GROUPS /
DWS_USER_RATE_LIMIT / DWS_KNOWLEDGE_DIR) for service-style runs.

go build ./... clean; go test ./internal/helpers -race passes with new
tests for lock/gate/queue/knowledge/media. test/cli_compat, test/scripts
and test/unit fail identically on the unmodified base (pre-existing
fixture issues).
2026-06-11 12:18:25 +08:00
玉澜 473d2f9aaf docs(devapp): document status handling 2026-06-11 12:00:27 +08:00
quanming 3dcb40c634 feat: add codex app-server robot bridge 2026-06-11 11:10:25 +08:00
玉澜 5e4b5e4eda docs(devapp): add one-click install guide 2026-06-11 10:26:17 +08:00
修雨 60ac0b409d fix(cli): guard canonical mcp tree against poisoned-cache flag collisions (#454)
* fix(cli): guard canonical mcp tree against poisoned-cache flag collisions

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

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

* docs: amend 1.0.36 changelog for the re-cut with the canonical tree guard (#454)
2026-06-11 09:16:36 +08:00
john d873b9c017 Merge pull request #7 from PeterGuy326/fix/connect-card-doc-hermes-guidance
docs(connect): fix stale CardTemplate comment + document AI-assistant reply window for hermes channel
2026-06-11 09:13:19 +08:00
修雨 4b8acc8e73 docs(connect): fix stale CardTemplate comment; document AI-assistant reply window in hermes channel guidance
- ConnectOptions.CardTemplate said an empty value falls back to the public
  openclaw template, but the code (newAICardClient/hasTemplate) treats empty
  as cards-off, and the explicit "public" alias exists precisely for that
  opt-in. Align the comment with the actual contract.
- hermes official-channel steps now warn about the trap verified live: a
  devapp robot renders replies through the AI-assistant response window, so
  a slow agent's plain-text reply gets the Done reaction (HTTP 200) but never
  shows up, leaving the loading card stuck. Point users to enabling AI cards
  on the hermes side.
2026-06-10 23:54:15 +08:00
修雨 4bc4b60dca docs: cut 1.0.36 changelog (#453) 2026-06-10 22:45:50 +08:00
修雨 31e65dda51 fix(cli): self-heal a poisoned discovery cache by quarantining it and rebuilding (#452)
A panic during the envelope-driven command build no longer just degrades
to helper commands (#447): the partition's discovery cache is first moved
aside to <partition>.quarantined (kept for inspection, previous quarantine
replaced) and the build retried once against a fresh fetch. Any path that
delivers a fixed binary -- dws upgrade or a reinstall -- now escapes the
lock-out with zero manual cache surgery; only a second panic (remote
envelope still poisoned, or offline) falls back to the degraded helper
set with the 'dws cache refresh' hint.

dws upgrade additionally clears the discovery-derived caches (market /
tools / detail across all partitions, leaving the downloads dir alone)
after the binary swap, so the upgraded binary rebuilds its command tree
from fresh data instead of inheriting snapshots written by the old
version.
2026-06-10 22:45:35 +08:00
xianfeng wang 0f65cb0064 Merge pull request #6 from PeterGuy326/feat/devapp-robot-connect
feat(devapp): robot connect 回复升级——AI 卡片 + Thinking/Done 状态 + 真流式
2026-06-10 21:58:49 +08:00
修雨 f013536aac Merge upstream feat/dws-devapp: adopt upstream's hardcoded-endpoint priority
Conflict resolutions:
- internal/app/direct_runtime.go: take upstream (532fcb4) — the maintainer
  stabilized the endpoint tests by documenting the hardcoded devapp endpoint
  as authoritative (tests renamed *DoesNotOverrideHardcoded), superseding our
  demote-to-fallback fix for the same failing tests.
- skills/mono devapp.md: union — keep our reply-card/card-template/CLI
  preflight bullets and upstream's Codex DWS_AGENT_CMD guidance.
2026-06-10 21:51:25 +08:00
Aemeath c56eb7f8c2 Merge pull request #5 from wxianfeng/codex/devapp-codex-connect-skill 2026-06-10 21:48:07 +08:00
修雨 f9abc79ecc feat(devapp): cards opt-in via template; default replies are plain text + reactions
Mirror hermes' semantics: without a configured card template, replies go as
plain text/markdown with the Thinking/Done reaction chips (which need no
template and always render); cards activate only when --card-template /
DWS_CARD_TEMPLATE is set ('public' opts into the openclaw shared template
explicitly). This removes the silent-failure trap where every card API call
succeeds but the client cannot render a template it is not authorized for —
the previous default left users staring at 内容加载失败 instead of falling
back to text.
2026-06-10 21:43:42 +08:00
修雨 99893e473a feat(devapp): CLI dependency preflight in robot connect dry-run
The channel CLIs (claude/codebuddy/qodercli/...) may be missing on the
user's machine. Runtime already preflights at connect (auto-installing
package-manager CLIs, erroring with a hint otherwise), but agents had no
machine-readable way to check BEFORE starting the connector. dry-run now
reports a cli block ({required, installed, path, autoInstall, installHint})
per channel — desktop-app channels and openclaw/hermes report their
onboarding hint. Skill docs gain the agent preflight workflow: check
dry-run first; npm channels may auto-install; desktop-app channels must
have the app installed by the user before connecting.
2026-06-10 21:32:25 +08:00
修雨 51b3316187 feat(devapp): configurable AI-card template via --card-template / DWS_CARD_TEMPLATE
Card templates are app-scoped — the hermes docs prescribe grabbing the
template ID from YOUR app's AI Card settings in the developer console, and
our live A/B confirmed another app's template renders 内容加载失败. Expose
the template as a flag (default: the openclaw public template, best-effort)
so operators can register an app-owned template for reliable, branded
rendering. Skill docs updated with the developer-console path.
2026-06-10 21:18:45 +08:00
shangguanxuan.sgx 9f744caafd chore(config): default mcp endpoint to prepub 2026-06-10 21:07:13 +08:00
InternZzp 532fcb4874 fix: stabilize direct runtime endpoint tests 2026-06-10 21:07:03 +08:00
修雨 28d556c5e9 fix(devapp): demote the built-in devapp endpoint to a fallback; gofmt
The hardcoded devapp endpoint was checked BEFORE dynamic registration and
edition static/supplement lookups, short-circuiting every configurable
source — the TestDirectRuntimeEndpoint_Devapp*WithoutRegistry tests encode
the intended priority (built-in = last resort so operators can repoint the
product without a rebuild) and have been failing on the branch. Move the
built-in check after Priority 4; env override > dynamic > PAT > edition >
built-in. Also gofmt two files the Lint job flagged.
2026-06-10 21:02:22 +08:00
修雨 c106de8361 feat(devapp): AI-card replies with thinking/done states and live streaming for robot connect
Bring the official-channel reply experience (hermes/openclaw) to every
stream-bridge channel:

- "🤔Thinking" reaction chip on the user's message while the agent runs,
  swapped to "🥳Done" when the reply lands — the hermes text-emotion contract
  (POST /v1.0/robot/emotion/reply|recall, emotionId 2659900).
- AI-card reply using the openclaw connector's public template + payload
  contract (msgContent + flowStatus state machine). Card templates are
  app-scoped: hermes' own template renders "内容加载失败" for any other app —
  confirmed by live A/B on the same robot.
- Live streaming into the card where the channel CLI supports it (verified
  per CLI): claude/codebuddy stream-json text deltas (thinking deltas
  filtered), qodercli stream-json per-turn snapshots; codex/gemini/opencode
  stay one-shot. Frames are full-content and throttled at 800ms per card.
- --reply-card flag (default on, env DWS_REPLY_CARD=0) with full fallback
  chain: any card failure → plain text/markdown webhook reply; stuck cards
  are best-effort marked failed.
- Deliver responses are checked for business-level failures inside HTTP 200
  ({"result":[{"success":false,...}]} observed live), and every card API
  response body is logged for operability.
- workbuddy persona now forbids tool use: headless codebuddy otherwise
  stalls on permission gates trying to write memory files.
- Skill docs: reply-card flag, version-publish approver-selection workflow
  (check-approval → present approver list to the user → publish --approver).

Known issue under investigation: after a batch of invalid-template test
cards, the reporting device renders all subsequent cards as "内容加载失败"
even though every API call succeeds and identical payloads rendered earlier
the same day — suspected client-side card/template cache poisoning; pending
verification on a second client.
2026-06-10 20:46:38 +08:00
shangguanxuan.sgx 2e11a23381 fix(keychain): add windows storage dir for packaging 2026-06-10 20:44:59 +08:00
InternZzp 2c1be7a8fb docs: document codex robot connect command 2026-06-10 20:44:45 +08:00
shangguanxuan.sgx e637d793b0 fix(pat): verify chmod fallback agent code 2026-06-10 20:40:15 +08:00
shangguanxuan.sgx 864f94e80e docs(skill): add devdoc rag troubleshooting guidance 2026-06-10 20:20:13 +08:00
shangguanxuan.sgx 7f1d36c9ac fix(pat): require yes for batch chmod 2026-06-10 17:25:12 +08:00
shangguanxuan.sgx 4eea61897d fix(pat): guard chmod agent code mismatch 2026-06-10 16:48:52 +08:00
修雨 387ae5ff59 fix(doc): strip leading H1 duplicating --name on doc create (#448)
* fix(doc): strip leading H1 duplicating --name on doc create

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

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

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

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

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

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

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

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

Wrap the envelope-driven build in a local recover: on panic the CLI now
logs the failure, prints a stderr hint pointing at 'dws cache refresh',
and falls back to the hardcoded helper commands so utility commands
stay alive and users can self-heal.
2026-06-10 15:28:58 +08:00
john c99ac87839 Merge pull request #3 from PeterGuy326/feat/devapp-robot-connect
feat(devapp): robot connect — 建联收口进 devapp 指令集(渠道感知 Stream 转发)
2026-06-10 12:08:52 +08:00
修雨 1b3319603d fix(devapp): surface Stream SDK connection lifecycle on stderr
The SDK's default logger is a doNothingLogger, so the connector printed
nothing on connect/reconnect/read errors — a dead connection was
indistinguishable from a healthy idle one. Wire an ILogger that writes
Info/Warning/Error to stderr (debug frames stay silent). Verified live:
'[stream] connect success, sessionId=...' now appears at startup.
2026-06-10 12:08:15 +08:00
修雨 995d490a76 feat(devapp): give robot connect session memory, model and workdir tuning
Users reported the linked bot feels dumb. Root cause: the forwarder was
hard-pinned to a stateless one-shot haiku run from an empty directory. Expose
the trade-offs as command-set flags instead of hardcoding them:

- --agent-memory (default on): per-conversation session resume. First message
  of a DingTalk conversation mints a UUID and passes `--session-id`,
  follow-ups pass `--resume`, so multi-turn context survives. Only on CLIs
  with addressable sessions (claudecode/codebuddy/workbuddy — verified against
  their --help); qodercli has --resume but no --session-id, so the qoder
  family stays stateless. Broken sessions self-heal: on forward error the
  conversation mapping is dropped and the next message starts fresh.
- --agent-model / DWS_AGENT_MODEL: override the channel CLI's model via each
  spec's model flag (replaces claudecode's built-in haiku pin in place).
- --agent-workdir / DWS_AGENT_WORKDIR: run the agent from a knowledge
  directory for context, instead of the clean temp dir (which stays the
  default to keep cold-start inside DingTalk's reply window).

A DWS_AGENT_CMD override disables flag splicing entirely — that argv is
user-owned and we cannot know which flags it accepts. Dry-run now previews
the effective agent tuning (model/workdir/memory) per channel.
2026-06-10 11:24:54 +08:00
修雨 5a401ddc1b fix(skill): detect ~/.qoder and ~/.qoderwork in skill setup, add qoderwork install path
Ported from PR #407: `robot connect` supports the qoder/qoderwork channels,
but skill setup neither probed ~/.qoder / ~/.qoderwork in its all-targets
checklist nor knew a qoderwork install path, so Qoder/QoderWork hosts missed
the skill routing. Consistency is enforced by TestAgentSkillPathsCoversSetupHomes.
2026-06-10 10:29:27 +08:00
修雨 f5bdb7118a feat(devapp): add robot connect to fold bot linking into the devapp command set
Bring the channel-aware Stream linking ("建联") from PR #407 into the devapp
command tree as `dws devapp robot connect`, so the full lifecycle
(create app -> create robot -> link to a local agent) lives under one
`dws devapp` domain instead of a separate `dws connect` command.

- Reuse, not duplicate: provisioning ("建号") stays on devapp's existing
  `robot create/submit/result`; this commit ports only the linking half.
- connect_stream.go: the Go-native in-process Stream forwarder + channel
  agent registry, ported verbatim from #407 (package helpers, self-contained).
- devapp_connect.go: trimmed channel routing (resolveConnectChannel /
  launchConnector / buildConnectPlan / ...) plus the new subcommand. The
  standalone `connect` cobra command, `connect start` and `connect bot create`
  are intentionally NOT brought over — the capability is consolidated under
  `dws devapp robot`.
- Credentials: `--robot-client-id` / `--robot-client-secret` for an existing
  robot, or `--unified-app-id` to auto-fetch via devapp's credentials get.
  The flags are named `robot-client-*` (not `client-id`/`client-secret`) to
  avoid shadowing the global OAuth client-override persistent flags.
- Adds the dingtalk-stream-sdk-go v0.9.1 dependency.
- Skill docs (devapp.md, robot.md mono+multi) document `robot connect`.

Known follow-up (marked in code + docs): the get_open_dev_app_credentials
response field names used by `--unified-app-id` auto-fetch are not yet
confirmed against the real gateway; the path degrades safely (empty -> fall
back to explicit flags) and must be verified against pre-prod before relying
on it.
2026-06-10 10:06:31 +08:00
shangguanxuan.sgx 4c86a9f8e1 fix(pat): carry agentCode in batch auth args 2026-06-10 09:49:53 +08:00
xuanandshangguanxuan.sgx eaa60f95b5 feat(devdoc): add rag mcp cli commands (#434)
* feat(devdoc): add rag mcp cli commands

* chore(config): default mcp endpoint to prepub

* chore(config): use prepub mcp discovery host

* fix(devdoc): wrap rag search request

* fix(transport): preserve dingtalk mcp gateway query

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

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

* test(devdoc): cover rag cli parameter mapping

* chore(test): remove dead nested arg assertion

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-06-09 18:00:05 +08:00
玉澜andClaude Opus 4.6 3117ad5d86 fix: restore credentials-webapp.md name, add gitignore exception
Add `!**/credentials-webapp.md` to .gitignore so the skill reference
doc is not blocked by the `credentials*` rule. Revert the cred-webapp
rename from the previous commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-09 17:35:09 +08:00
玉澜andClaude Opus 4.6 0e484bb189 fix: rename credentials-webapp.md to cred-webapp.md to avoid gitignore
.gitignore rule `credentials*` was blocking the file from being tracked.
Rename to cred-webapp.md, update SKILL.md links, and fix workflows.md
stale text (version is now implemented, only event remains pending).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-09 17:31:18 +08:00
wxianfeng de4a292174 devapp robot version 2026-06-09 15:58:01 +08:00
玉澜andClaude Opus 4.6 ad43e1ba28 docs: add pagination (--limit/--offset) to permission list skill docs
Permission list can return 150+ items. Document --limit and --offset
for paging across all three skill locations (standalone, multi, mono).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-09 15:43:37 +08:00
InternZzp ac825ad293 fix(devapp): use placeholders in security examples 2026-06-09 14:17:00 +08:00
玉澜andClaude Opus 4.6 8411211a8c docs: complete workflows.md with all 17 commands verified
Add missing flows: webapp get verification, member list/remove,
security config. All 17 devapp commands now E2E verified:
- member list/add/remove: tested on PR407验收 app
- security config: IP whitelist tested
- webapp get: verified returns after config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-09 14:02:34 +08:00
玉澜andClaude Opus 4.6 449d731239 docs: refactor devapp skill to progressive layered structure
Restructure all three devapp skill locations (standalone, multi, mono)
from monolithic flat docs into progressive layered references:
- SKILL.md: lean routing entry (45 lines vs 325 before)
- references/app-crud.md: list/get/create/update/inactive/active/delete
- references/credentials-webapp.md: credentials get, webapp get/config
- references/permissions.md: permission list/add/remove
- references/member-security.md: member list/add/remove, security config
- references/workflows.md: end-to-end operational flows

Also adds inactive/active commands missing from prior skill docs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-09 13:59:15 +08:00
玉澜andClaude Opus 4.6 13d77ee7b7 feat: add credentials get command and hardcode devapp endpoint
- Add `dws devapp credentials get` helper command calling
  `get_open_dev_app_credentials` MCP tool
- Hardcode devapp endpoint to pre-mcp-gw.dingtalk.com/server/op-app
  in direct_runtime.go (same pattern as robotCreate in PR 407)
- Update docs/skills from "Pending" to "Implemented" for credentials
- Remove obsolete showSecret/confirmSecret references
- Expand lifecycle tests to table-driven covering delete/inactive/active

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-09 13:40:33 +08:00
InternZzp 694288cfbc feat(devapp): guard member and security writes 2026-06-09 10:41:19 +08:00
修雨 e7a3010b81 docs: cut 1.0.35 changelog + fix README product-count drift (#435)
* docs: cut 1.0.35 changelog + fix README product-count drift

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

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

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

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

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

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

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

* docs(changelog): write 1.0.35 entries in English

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

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

Verified live: @someone and @all now render as blue mentions in the client.
2026-06-08 21:46:41 +08:00
玉澜 6f5a0afdf7 docs: sync devapp webapp skill guidance 2026-06-08 20:48:40 +08:00
玉澜 248c6602ac Merge remote-tracking branch 'origin/feat/dws-devapp' into feat/dws-devapp
# Conflicts:
#	docs/devapp-yulan-command-routing.md
#	skills/mono/references/products/devapp.md
#	skills/multi/dingtalk-devapp/SKILL.md
#	skills/multi/dingtalk-devapp/references/devapp.md
2026-06-08 19:32:57 +08:00
玉澜 9589c23796 feat: complete devapp command routing 2026-06-08 19:07:30 +08:00
玉澜 9460437c9a test: add ambiguous app routing cases 2026-06-08 17:53:16 +08:00
玉澜 34d691906e test: cover devapp intent disambiguation 2026-06-08 17:53:16 +08:00
玉澜 a78765bb42 docs: clarify devapp branch status 2026-06-08 17:53:16 +08:00
玉澜 836f5bdb25 feat: add devapp command routing design 2026-06-08 17:53:16 +08:00
修雨 3282957958 Revert "fix(cli): JSON errors to stdout; no interactive confirm on piped stdin (#413)" (#415)
This reverts commit f826375556.
2026-06-08 17:53:16 +08:00
xuanandshangguanxuan.sgx 252c61aff6 fix(pat): pass agent code to chmod batch tools (#414)
Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-06-08 17:53:16 +08:00
修雨 ffefc53861 fix(cli): JSON errors to stdout; no interactive confirm on piped stdin (#413)
- printExecutionError: in JSON mode emit the structured error to stdout
  (not stderr) so machine consumers parsing stdout get a parseable result;
  update the 3 root_execute tests to assert the new contract.
- requireYesForDelete prompt: when stdin is not a TTY (piped/scripted/JSON
  pipelines), do not show an interactive confirm that emits non-JSON and
  blocks on stdin; return a structured validation error requiring --yes.
2026-06-08 17:53:16 +08:00
InternZzp 8cbc56edd2 feat: add devapp helper commands 2026-06-08 17:24:59 +08:00
shangguanxuan.sgx fc0873b0c6 docs,test(pat): carry chmod batch auth updates 2026-06-08 15:36:57 +08:00
修雨 8c2093a41a fix(skill): align chat single-chat docs/script with list-direct (#424)
* fix(skill): align chat single-chat docs/script with list-direct

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

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

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

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

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

list-direct 部分保留不变——v1.0.34 确认 `list` 仅群聊、`list-direct`
存在,`list --user` 仍报 unknown flag。单聊发送回归 `send --user`。
2026-06-07 16:17:31 +08:00
玉澜 eea85bd989 test: add ambiguous app routing cases 2026-06-05 17:08:55 +08:00
玉澜 43560afa78 test: cover devapp intent disambiguation 2026-06-05 16:30:12 +08:00
玉澜 7ef46c1288 docs: clarify devapp branch status 2026-06-05 16:20:38 +08:00
玉澜 08413a6903 feat: add devapp command routing design 2026-06-05 16:12:17 +08:00
玉澜 c488421423 Merge remote-tracking branch 'upstream/main' into feat/dws-devapp 2026-06-05 15:46:45 +08:00
修雨 5fbf12fe50 Revert "fix(cli): JSON errors to stdout; no interactive confirm on piped stdin (#413)" (#415)
This reverts commit f826375556.
2026-06-05 12:10:17 +08:00
xuanandshangguanxuan.sgx dd419ca498 fix(pat): pass agent code to chmod batch tools (#414)
Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-06-05 11:23:48 +08:00
修雨 f826375556 fix(cli): JSON errors to stdout; no interactive confirm on piped stdin (#413)
- printExecutionError: in JSON mode emit the structured error to stdout
  (not stderr) so machine consumers parsing stdout get a parseable result;
  update the 3 root_execute tests to assert the new contract.
- requireYesForDelete prompt: when stdin is not a TTY (piped/scripted/JSON
  pipelines), do not show an interactive confirm that emits non-JSON and
  blocks on stdin; return a structured validation error requiring --yes.
2026-06-05 11:22:22 +08:00
wxianfeng b3e8783c4f Merge remote-tracking branch 'upstream/main' 2026-06-04 18:01:41 +08:00
xuanandshangguanxuan.sgx cb95207d5a fix(pat): keep auth URLs readable in JSON output (#401)
* fix(pat): keep auth URLs readable in JSON output

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

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-06-04 15:57:06 +08:00
wxianfeng 1c2c96617a Merge remote-tracking branch 'upstream/main' 2026-06-03 20:21:59 +08:00
wxianfeng 5fe366eddd fix conflict 2026-06-03 20:21:54 +08:00
Ari 1e95d03606 Merge pull request #405 from audanye-sudo/feat/switch-discovery-url
fix: version-code discovery path and take aiapp product offline
2026-06-03 19:45:39 +08:00
修雨 6e3f3cbd24 chore: take aiapp product offline
Remove the entire code surface of the aiapp (AI application: create/query/modify) product:
- Delete internal/helpers/aiapp.go and its test (the init() static command registration)
- Drop aiapp from generator coverage targets and knownRegistryProducts
- Remove aiapp from the operations-analyst persona services in personas.yaml
- Delete skills (mono references/products/aiapp.md + dingtalk-aiapp multi skill + script)
- Update README/README_zh product tables and counts (19->18 products, 20->19 multi skills) and run_skill_tests mapping
- Add a Removed entry to CHANGELOG

The service-discovery side (envelope/discovery.pre.json, Portal-synced and gitignored) removes the aiapp server block separately.
2026-06-03 19:41:05 +08:00
audanye-sudo ce5e919c52 fix(market): version-code discovery path as /cli/discovery/apis/bamboo
Extract the server-list endpoint path into a single discoveryAPIPath constant and move it from /cli/discovery/apis to /cli/discovery/apis/bamboo so future version bumps touch one place.

Only the path changes: the MCP base host stays on production https://mcp.dingtalk.com and the auth / skill / doctor endpoints are untouched. The edition DiscoveryURL hook (full-URL FetchServersFromURL) is unaffected. All mock/test fixtures are updated to the new path and a CHANGELOG 1.0.34 entry is added.
2026-06-03 19:41:04 +08:00
wxianfeng a7d109384b merge upstream 2026-06-03 19:14:19 +08:00
修雨 c75ed45c70 fix(keychain): add StorageDir for Windows to unblock v1.0.33 release (#394)
* docs(chat): 资源下载分流改为按 host 判定,补 media 直链可裸 curl(整合 #376)

main 现状的「资源链接形态分流」段补三类增量,supersede #376:
- 判定依据从「链接形态」改为「链接 host,不由扩展名」
- media(down.dingtalk.com/media)点明为公开 CDN,download-media 命令之外裸 curl 也可直下
- 补 .unknown 用 file 判真实类型、图片 AI 描述说明
- 保留 download-media 官方命令路径,删去 #376 误判「无 mediaId 下载命令」一句

* docs(chat): 修正 media 下载分流——host 非唯一、首选 download-media、签名链勿裸 curl

修复 PR 原版对 media 链接的过头断言(评审发现):
- 点明图片 mediaId 链接 host 不唯一:down.dingtalk.com/media(公开)或 *.trans.dingtalk.com 签名+过期链
- 下载主路改为首选 download-media(取最新 URL,不受过期影响);裸 curl 仅限公开直链
- 删去「无需鉴权 / curl 均 HTTP 200」的普适断言(签名链过期后 curl 403)
- 保留 .unknown 用 file 判类型、AI 图片描述、钉盘/alidocs 分流

* fix(keychain): add StorageDir for Windows to unblock cross-platform release

portable_store.go (#357) references keychain.StorageDir on all platforms,
but only keychain_darwin.go / keychain_linux.go defined it — the Windows
backend (DPAPI + registry) lacked StorageDir, so GoReleaser's
windows/amd64 + windows/arm64 cross-compile failed with
'undefined: keychain.StorageDir' and broke the v1.0.33 release.

Add StorageDir to keychain_windows.go: DWS_KEYCHAIN_DIR override →
%LocalAppData%\<service> default, mirroring the linux/darwin logic.
Verified: go build passes for windows/darwin/linux x amd64/arm64.
2026-06-02 23:37:06 +08:00
修雨 a8b1670ad9 docs: 同步 CHANGELOG 1.0.33 与 README 命令清单(打 tag 前) (#393)
CHANGELOG:新增 [1.0.33] 版本块,归并 #391 整批能力(doc 文档族 / wiki 知识库 /
aiapp / aitable 表单与导入导出 / mail / todo / report 可读日志)、可迁移认证包
auth export/import (#357)、PAT 批量授权与精简摘要输出 (#389)。

README(中英双版):按当前命令树实测校准各产品命令数(contact 6→15、todo 6→16、
report 7→20、mail 4→18、wiki 7→21、oa 9→15、aitable 42→52、doc 21→28;sheet 34→23
并删除已移除的 copy_sheet/submit_export_job 等命令),总数 213→334,补全新增命令组。
2026-06-02 22:46:28 +08:00
johnand玉澜 8c4bd71964 fix(doc): align export progress output (#392)
Co-authored-by: 玉澜 <yulan.wqy@alibaba-inc.com>
2026-06-02 22:26:03 +08:00
shangguanxuan.sgx 9e7e7dbc7f Merge remote-tracking branch 'upstream/main'
# Conflicts:
#	.github/badges/coverage.svg
2026-06-02 21:30:14 +08:00
539d10f80f feat: 合并 pre-mcp-discovery 分支能力到 main(服务发现保持生产) (#391)
将 test/pre-mcp-discovery 分支上多位同学的能力改动整体合入 main,
服务发现端点保持生产 https://mcp.dingtalk.com,未携带分支上的预发改动。

主要内容:
- 修雨域(chat / report / todo / contact / mail)对齐 wukong 基线 (#355)
- DWS 内容生产能力增强 (#387)、文档导出与 OA 对齐 (#362 #388 #390)
- envelope 注册 cli.Aliases 为 cobra aliases、命令结构与 JSON 解析优化
- skills 多包同步 wukong 对齐内容(attendance/calendar/minutes/oa/sheet 等)
- product/oa 进一步对齐 wukong

排除项:未携带 566b4e1(test: point service discovery to pre-mcp.dingtalk.com);
以下 4 个常量保持生产:skill_command.go / auth/endpoints.go / cli/loader.go / market/registry.go。

Co-authored-by: john <32427341+wqyenjoy@users.noreply.github.com>
Co-authored-by: wxianfeng <wang.fl1429@gmail.com>
2026-06-02 21:04:12 +08:00
56a5edecef feat(pat): support batch chmod authorization flows (#389)
* feat(pat): support batch chmod flows

* feat: use server default agentCode for chmod

* feat: surface tool auth metadata

* feat: infer product grant auth metadata

* chore: update coverage badge [skip ci]

* feat(pat): summarize chmod output by default

* chore: update coverage badge [skip ci]

* fix(pat): preserve batch session metadata

* fix(pat): align dry-run session and schema docs

* chore(config): use public example MCP override URL

* fix(pat): align chmod session env handling

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-02 20:54:55 +08:00
shangguanxuan.sgx 7652bda320 fix(pat): align chmod session env handling 2026-06-02 20:38:38 +08:00
shangguanxuan.sgx dd1c4e34fd chore(config): use public example MCP override URL 2026-06-02 16:01:38 +08:00
shangguanxuan.sgx e653616aa3 fix(pat): align dry-run session and schema docs 2026-06-02 14:56:23 +08:00
shangguanxuan.sgx 3ce64db2fc fix(pat): preserve batch session metadata 2026-06-02 14:36:59 +08:00
github-actions[bot] 4bbd52fc58 chore: update coverage badge [skip ci] 2026-06-02 01:38:39 +00:00
shangguanxuan.sgx 9eb3099881 feat(pat): summarize chmod output by default 2026-06-02 09:36:24 +08:00
github-actions[bot] 73e2de7fe8 chore: update coverage badge [skip ci] 2026-06-01 14:18:49 +00:00
shangguanxuan.sgx 8b4f05e44e Merge release/v1.0.29 into main 2026-06-01 22:09:55 +08:00
shangguanxuan.sgx a7879edca1 feat: infer product grant auth metadata 2026-06-01 21:02:15 +08:00
shangguanxuan.sgx 3f09eb5c0f feat: surface tool auth metadata 2026-06-01 20:50:15 +08:00
修雨 4ebc8d0d38 fix(plugin): silence stdio discovery handshake failures (Warn → Debug) (#380)
The conference-local stdio plugin runs an Initialize/ListTools handshake
during command-tree construction. When the DingTalk desktop client isn't
running it returns "本地服务未就绪", which was logged at Warn and printed
to stderr on every invocation.

Because discovery happens in NewRootCommandWithEngine — before
PersistentPreRunE applies --debug/--verbose via configureLogLevel — this
Warn showed regardless of flags, polluting output and misleading callers
(and LLMs) into treating it as the cause of an unrelated command error
(e.g. an auth error or PARAM_ERROR from a different server).

This is an expected, benign outcome for an optional local plugin:
commands that ship toolOverrides still register up-front via
registerStdioServerFromOverlay, so availability is unaffected. Downgrade
both discovery handshake failures to Debug so normal output stays clean.
2026-06-01 17:11:52 +08:00
shangguanxuan.sgx 202cb509a0 feat: use server default agentCode for chmod 2026-05-29 16:48:09 +08:00
shangguanxuan.sgx dd42fd833b feat(pat): support batch chmod flows 2026-05-29 15:22:00 +08:00
4e58e45d30 feat(auth): add export/import for portable sandbox credentials (#357)
* feat(auth): add export/import for portable sandbox credentials

Enable migrating encrypted keychain and config between Linux sandboxes so refresh tokens survive beyond the 2-hour access token window.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(auth): harden portable export/import per PR review

按 PR 审查意见加固可迁移认证包流程:macOS 默认 Keychain
场景拒绝导出;导入需 --force 覆盖已有登录态;解析 manifest
并在 OS 不匹配时告警;export 参数校验前置;flag 描述中文化;
移除已弃用的 tar.TypeRegA;导出文件后提示删除敏感包。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(auth): require auth-token.enc before portable export

修复 staticcheck S1030(测试改用 exported.Bytes())。
导出前校验 auth-token.enc 存在,避免无登录态时生成
仅含 dek 的无效 bundle;CLI 提示先运行 dws auth login。

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: 洛丘 <wangketing.wkt@antgroup.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 09:19:11 +08:00
github-actions[bot] b5f241c719 chore: update coverage badge [skip ci] 2026-05-28 04:16:11 +00:00
wxianfeng 4cdc3e7320 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-05-28 12:13:45 +08:00
wxianfeng b558bda6df Merge remote-tracking branch 'upstream/main' 2026-05-28 12:11:15 +08:00
修雨 5ce7cb61b0 feat(skill): dws skill setup with mono/multi modes (#332)
新增 `dws skill setup` 交互式安装命令,支持单产品(mono)和多产品(multi,标记为试验版)两种 skill 布局,并与开源 dws v1.0.30 命令对齐。

主要变更:
- 骨架:skills/ 拆为 skills/mono/ 和 skills/multi/(29 个产品 skill),git rename 保留历史
- 命令:新增 `skill setup` cobra 子命令,支持 --mode、--target、--source、--yes,huh 交互
  - multi 模式支持 -s/--skill、-x/--exclude 按产品选择
  - 覆盖全部 14 个 agent home 目录(与 install.sh 一致)
- 发布:install.sh / install.ps1 / post-goreleaser.sh 改读 skills/mono;dws-skills.zip 同时包含 mono 和 multi 内容;~/.dws 本地缓存
- 内容对齐:删除 8 个仅内部使用的产品 skill、dingtalk-a2a;修正 mono/multi 共 15+ 处命令引用以匹配 OSS dws v1.0.30;chat skill 从 dws-wukong 回移更新
- 漂移清理:物理删除 skill 文档中 OSS 未暴露的命令,true-fail 命令转为 caveat;--help diff 100% 覆盖审计
- 测试:新增 Layer A 静态命令调度验证 + Layer B 端到端 mock 调度验证
- 同步 main:drive 9 个命令同步到 mono/multi skill
2026-05-26 09:17:37 +08:00
修雨 86ff7e2f50 docs(changelog): add 1.0.32 release notes (#354) 2026-05-25 19:54:47 +08:00
github-actions[bot] 1063a6425d chore: update coverage badge [skip ci] 2026-05-25 06:15:24 +00:00
wxianfeng 0a8517432f Merge remote-tracking branch 'origin/main' 2026-05-25 14:12:18 +08:00
wxianfeng 83bfca8d35 Merge remote-tracking branch 'upstream/main' 2026-05-25 14:09:29 +08:00
github-actions[bot] 9e58a062f8 chore: update coverage badge [skip ci] 2026-05-25 06:06:21 +00:00
Ari 45cb237f74 fix(drive): unblock OSS upload + improve AI-agent discoverability (#347)
* fix(drive): drop client-side Content-Type fallback on presigned OSS PUT

The drive helper used to set req.Header["Content-Type"] = fallbackMIME
whenever the prepare_upload response returned an empty headers map.
DingTalk drive uses OSS presigned URLs whose StringToSign is computed
against an empty Content-Type at signing time, so any client-side
Content-Type makes the signature OSS recomputes at PUT time differ
from the server's presignature → HTTP 403 SignatureDoesNotMatch.

This broke every `dws drive upload` for any file whose mime detects
to a non-empty value (image/png, application/pdf, etc.), which is
basically everything in practice.

Reproduction (against DingTalk drive):
  dws drive upload --file any.png
  → [1/3] 获取上传凭证 any.png (X 字节, image/png)...
  → [2/3] 上传文件到 OSS...
  → OSS 上传失败 HTTP 403: SignatureDoesNotMatch

Fix: trust the server's headers map as authoritative. Empty map means
"no client-side headers needed" — do not infer and add Content-Type.
Removes the hasContentType / fallbackMIME path entirely; httpPutDriveFile
signature loses the fallbackMIME parameter.

Manual verification:
  curl -X PUT -H "Content-Type:" --data-binary @file <same-presigned-url>
  → HTTP 200 (proves the only difference was the client-side Content-Type)

Note: aitable.go's upload-file helper deliberately keeps its
Set("Content-Type", mimeType) call because its OSS endpoint uses a
different signing mode (server includes the client-declared mime in
its signature computation, verified by running aitable upload-file
across 12 file types — all succeed). The two helpers must not be
unified without re-validating both endpoints.

Tests:
  - TestHttpPutDriveFile_NoContentTypeWhenServerHeadersEmpty: guards
    that an empty server headers map results in no Content-Type on PUT.
  - TestHttpPutDriveFile_PassthroughServerHeaders: guards that server
    headers (Content-Type, x-oss-*) are forwarded verbatim.

* feat(aitable): unhide attachment upload-file + clarify prepare-only command

AI agents that discover commands only via --help (e.g. Lobster, generic
LLM agents) currently hit a dead end when trying to upload an attachment
to an AITable:

  - `dws aitable attachment` exposes only `upload` (prepare-only),
    which returns uploadUrl + fileToken but does not actually upload.
  - The real one-shot command `attachment upload-file` (which performs
    prepare + HTTP PUT + return fileToken automatically) is marked
    Hidden:true, so it is invisible in --help output.

Agents that don't read skills/references/products/aitable.md therefore
get stuck after step 1 — they call `upload`, receive an uploadUrl they
have no idea how to consume, attempt various wrong things (e.g. write
the uploadUrl into the record's attachment field as if it were a token),
fail, and fall back to "please use the UI to upload" messages, which
makes dws look broken even though the capability is fully implemented.

This is the same UX gap that lark-cli avoids with its highly visible
`base +record-upload-attachment` command — discoverability via --help
is the difference between "works for any agent" and "only works if the
agent reads our skill docs".

Changes:

  - newAITableUploadFileCommand: remove Hidden:true so the command
    appears in `dws aitable attachment --help`. Tighten Short to
    explicitly mention the 3 steps it bundles. Long now also calls
    out the prepare-only sibling and recommends this command as the
    default for AI agents.

  - newAITableAttachmentUploadCommand (prepare-only): add a Long
    description that explicitly states this command is only step 1
    of a 3-step flow, lists what an agent must do after (HTTP PUT,
    then write fileToken into record attachment field with the exact
    [{"fileToken":"ft_xxx"}] shape), and points to upload-file as
    the recommended one-shot alternative. Updated Short to flag that
    no file is uploaded by this command.

  - aitable_upload_file_test: add TestAITableUploadFileCommandIsDiscoverable
    to guard against re-introducing Hidden:true. The test includes a
    rationale comment explaining the agent-discoverability gap.

The drive Content-Type fix in the preceding commit and this aitable
discoverability fix together restore end-to-end attachment upload
functionality for both human operators and AI agents that only read
--help.

* feat(root): show 'dws upgrade' guidance in dws --help when commands are missing

AI agents (and users) reading `dws --help` see only the discovered MCP
service list and utility command list, with no hint about what to do if
none of the listed commands fit their task. The natural failure mode is
to give up or hack around — but in many cases the right action is just
`dws upgrade`, because new capabilities and bugfixes ship continuously
and a missing command is usually a stale binary issue.

Two changes restore visibility of the root command's Long description so
this guidance can be surfaced:

  - internal/app/root.go: set root.Long to a one-paragraph hint —
    "if you hit a missing command, an error, or cannot complete the
    task, try `dws upgrade` first; both the DingTalk OpenAPI surface
    and dws CLI evolve continuously."

  - internal/app/root_help.go: the custom SetHelpFunc that renders the
    root help (renderRootHelp) replaces cobra's default template, which
    had been silently dropping root.Long from --help output. Restore
    rendering by appending root.Long (when non-empty) after the command
    list, separated by a blank line. This matches cobra's default
    behavior for the Long field while preserving the custom services /
    utilities sections renderRootHelp introduced.

  - internal/app/visibility_test.go: new TestRenderRootHelpIncludesLong
    guards against re-introducing the regression. Uses a sentinel Long
    string and asserts renderRootHelp output contains it verbatim. If a
    future change rewrites the help renderer without preserving Long
    rendering, this test fails immediately and reminds the author the
    upgrade hint must stay visible.

Verified locally:
  $ ./dws --help | tail -3
  Use "dws <service> --help" for more information about a discovered MCP
  service or "dws <command> --help" for utility commands.

  提示: 如果遇到能力缺失、命令报错、新功能未注册、或无法完成任务,
  请先用 'dws upgrade' 升级到最新版本后再试. 钉钉 OpenAPI 和 dws CLI
  持续迭代, 新能力和 bugfix 会先在新版本上线.
2026-05-24 15:55:08 +08:00
修雨 bd711108f9 fix(upgrade): ad-hoc sign darwin binaries to fix amfid SIGKILL on Apple Silicon (#339)
GoReleaser cross-compiles darwin/arm64 binaries on ubuntu-latest with no
codesign step. macOS 11+ on Apple Silicon requires at least an ad-hoc
signature; unsigned arm64 binaries are SIGKILL'd by amfid on first exec,
which surfaces as `signal: killed` and aborts `dws upgrade` at the
"解压并验证" step.

Two layers of fix:

1. Release-side: post-goreleaser.sh now unpacks each dws-darwin-*.tar.gz,
   applies an ad-hoc signature (codesign locally, rcodesign in CI),
   deterministically repacks, and rewrites the matching line in
   checksums.txt. release.yml installs rcodesign 0.27.0 before
   GoReleaser runs.

2. Client-side self-heal: validateNewBinary detects `signal: killed` on
   darwin and retries once after running `codesign --force --sign -` and
   clearing com.apple.quarantine. Future releases stay functional even
   if the signing step is ever skipped.

Verified end-to-end: stripped a real dws binary → exec exits 137 →
validateNewBinary recovers → final binary shows Signature=adhoc and runs.
2026-05-21 22:45:22 +08:00
修雨 6b4d808d39 docs(changelog): add 1.0.31 release notes (#338) 2026-05-21 20:22:57 +08:00
修雨andClaude Opus 4.7 c7d8ddf98d feat: align drive with wukong — helper (upload) + skill docs (doc/sheet dingpan URL) (#335)
* feat(helpers): drive upload 三步合成胶水(list-spaces / delete 由 envelope 接管)

业务背景:开源版 dws drive 仅有来自服务发现 envelope 的 6 个工具,悟空版多
3 个能力 — list-spaces / upload / delete。这 3 个里只有 upload 是真正的客户端
胶水(PUT 文件二进制到 OSS 的中间 HTTP 步),其它两个完全可以通过 envelope
toolOverrides 表达,无需 Go 代码。

按 envelope 优先原则拆分:

1. envelope/pre-discovery.pre.json (单独提供 / 不在本 commit)
   - drive.toolOverrides.list_spaces  →  cliName: list-spaces
   - drive.toolOverrides.delete_document → cliName: delete, serverOverride: doc,
                                             isSensitive: true
2. internal/helpers/drive.go (本 commit)
   - 仅注册 upload 一个 leaf
   - 原因:envelope.PipelineStep 只支持 type:"call"/"download",没有
     type:"upload"。客户端流式 PUT 本地文件到 OSS 签名 URL(含 per-URL
     headers)当前 envelope schema 表达不了,故保留为 helper。

internal/helpers/drive.go (新增 ~290 行)

  - upload — 三步合成:
    1) drive get_upload_info → resourceUrl + uploadId + headers
    2) HTTP PUT 文件二进制 → OSS(沿用 aitable upload-file 模式,
       10min timeout,跟随 per-URL headers)
    3) drive commit_upload → 提交入库
    --dry-run 输出三步 invocation 预览不实际请求。
    parseDriveUploadInfo 兼容 content/result 包裹层与 resourceUrls 数组
    及 flat resourceUrl/uploadUrl fallback,与悟空侧 parseDriveUploadInfo
    等价。
    validateDriveParentID 拒纯数字 dentryId(防混淆 chat 链路 dentryId 与
    drive 链路 dentryUuid)。

注入路径:helpers.RegisterPublic → pickCommands.MergeHardcodedLeaves(dynamic,
helper)(dynamic 赢冲突、helper 填空白)。本 helper root.Use="drive" 不覆盖
dynamic 的 6 个 leaf,仅追加 upload。

dry-run 实测:

  $ dws drive upload --file /etc/hosts --dry-run --format json
  → step_1_get_upload_info + step_2_http_put_oss + step_3_commit_upload 三步预览

测试结果:
  ok  internal/helpers (含全部既有 TestSendByBot* / TestAtomicWrite* /
                       TestValidate* 等 23 个测试)
  ok  test/integration/extensions
  test/cli_compat pre-existing failures 与本改动无关(在干净 origin/main HEAD
  同样 fail,fixture 缺失等)。

未来工作 (follow-up):
  - 等 envelope 文件 commit 到主线后,list-spaces / delete 自动从 envelope
    生成 cobra command,无需此 helper 操心
  - 如果 envelope schema 加入 type:"upload" pipeline 步类型,本 helper 整个
    可以删除,由 envelope pipeline 表达完整三步

关联:
  - 悟空源头实现:dws-wukong/wukong/products/drive.go
    (runDriveUpload / parseDriveUploadInfo / httpPutFile + delete 路由)
  - 配套 skill 文档 PR:#333 (doc 端钉盘 URL 识别)
  - envelope 改动: drive.toolOverrides 增加 list_spaces + delete_document,
    见对应的 envelope PR / commit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(skill): doc --node 接受钉盘 document/edit|preview?dentryKey URL + 新增 url-patterns 分流

业务背景:hho 业务侧用户在悟空版上已用钉盘 URL(alidocs.dingtalk.com/document/edit?dentryKey=...)
直接喂给 Agent,但开源版 skill 文档未覆盖这种格式,Agent 不知道整段 URL 应原样传给 --node,
经常误把 dentryKey 当成裸 nodeId 调用导致失败。

本 PR 把悟空 dws-wukong PR #82157526 的 skill 文档增量移植到开源主线,保持 doc/sheet
两端入口的 URL 识别策略对齐悟空:

skills/SKILL.md
- 核心流程新增 Step 0「URL 预检」:含 alidocs URL 必须先读 url-patterns.md 分流,再选择产品
- 详细参考目录加入 url-patterns.md 引用

skills/references/products/doc.md
- dws doc info/read 的 Example 各 +2 行钉盘 URL 示例
- 「URL 识别与 DOC_ID 提取」支持的 URL 格式表新增 document/edit|preview?dentryKey={key} 一行
- 提取规则拆成 3 条,禁止 Agent 自行提取 dentryKey 当裸 nodeId
- 「nodeId 双格式说明」升级为「nodeId 多格式说明」,给出 4 种 --node 输入等价示例

skills/references/url-patterns.md (新建)
- 沉淀 alidocs URL 5 类分流决策:/i/p/ 短链 / /i/nodes/ 节点 / /spreadsheetv2/ 直链 /
  /document/edit|preview?dentryKey 链接 / 其他
- 含 i/nodes/ 类型探测流程(doc info 探 contentType/extension/nodeType 后再选产品)
- 含分享短链 read_url 兜底处理 + 动态渲染失败时的标准答复

skill 命名形态兼容:
- 当前 main 上 skills/ 是扁平结构,改动直接落在 skills/* 路径
- 单点(mono)/ 多点(multi)拆分形态在 feature/skill-setup 分支,待该分支合主线时
  按相同语义同步到 skills/mono/* 和 skills/multi/dingtalk-{doc,sheet}/* 两套

不涉及 Go 代码变更,纯 skill 文档;钉盘 URL 解析逻辑由 MCP 服务端承担。

关联悟空 PR:dws-wukong commit 565c63f8 / 712da968(#82157526)
对应 wukong skill 包:dingtalk-workspace/{SKILL.md,references/products/doc.md,references/url-patterns.md}

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:38:00 +08:00
修雨 754b0df056 docs(changelog): add 1.0.30 release notes (#322)
* docs(changelog): add 1.0.30 release notes

Notes for the wukong-IM-envelope alignment + schema-pipeline / transform /
market enhancements that landed via #317. Also extends the README "Pipe &
File Input" section with the `@<text>` ASCII-prefix rule that lets literal
Chinese mentions like `@所有人` / `@张三` pass through unchanged.

Validated against `dws-wukong/auto-test/cli_to_mcp` with `--edition open`,
account wukong01, on this main:
- chat: 107 passed / 0 failed / 0 errored → 100.0% PASS
- report: auto-test/cli_to_mcp/open_report/test_report_20260519_155449.md

* docs(changelog,readme): drop internal validation note; refresh chat row

- CHANGELOG: remove the validation paragraph from the 1.0.30 entry — it
  cited an internal repo path, a test account, and a local-only report
  file, none of which belong in public release notes.
- README / README_zh: bump the Chat / IM row in the Key Services table
  from 23 → 57 leaves and expand the subcommand list to match the
  current chat tree (group-mute / group-mute-member / mute / set-top /
  list-categories / list-conversations, plus message reply, search /
  search-advanced, forward, emoji & text-emotion reactions, cards,
  group member-role CRUD, transfer-owner, set-admin, quit, ...). Count
  matches `dws chat --help` enumeration on this main.
2026-05-19 17:29:35 +08:00
修雨 6be124777f feat: align CLI to wukong IM envelope + schema pipeline / transform / market enhancements (#317)
把 test/pre-mcp-discovery 上累积的稳定改动整理成一个 commit 合入 main,剔除
test-only 的预发环境硬编码与 merge/revert 噪音。

- internal/helpers: 重写 chat.go 对齐 wukong IM envelope,拆分共享的 chat
  命令,删减历史测试桩
- internal/compat: 新增 Pipeline tool override + executor、CLIAliases
  override、json_parse_strict 与 file_read transform;envelope leaf cmd
  Args 从 NoArgs 放宽到 ArbitraryArgs
- internal/cli: stdin 处理增强,覆盖更多 --content / --content-file 路径
- internal/app: direct_runtime / runner 适配嫁接硬编码 helper 到动态命令树
- internal/market: registry 强化 + 单元测试

最终 diff: 12 个文件,+369 / -387。
2026-05-19 15:25:39 +08:00
修雨 355a1460d9 docs(changelog): add 1.0.29 release notes (#309)
CHANGELOG: write up 1.0.29 — summary paragraph, then Added (3 new
envelope products: aiapp / live / aisearch with their flag aliases /
subcommand aliases / short flags rationale), Fixed (#306 leaf cmd
ArbitraryArgs), and the existing Security entry (#300 app.json
edition partitioning) preserved.

README.md / README_zh.md: lift aiapp / aisearch / live out of "Coming
soon" into the Key Services table; rename "Coming soon" to keep only
conference; bump totals 16 → 19 products / 204 → 209 commands.
2026-05-17 17:59:28 +08:00
c6edc84e40 fix(auth): partition app.json filename by edition to isolate credentials (#300)
* fix(auth): partition app.json filename by edition to isolate credentials

Two dws binaries sharing the same config directory (typically ~/.dws or
DWS_CONFIG_DIR) previously read and wrote a single app.json. Editions
that pin AuthClientID via hooks still go through the open-core
post-login persistence path, which records a bare ClientID without a
paired ClientSecret. The sibling edition reading the same path would
then adopt that foreign clientID via ResolveAppCredentials.

Mirror the strategy already used by the cache loader
(pkg/config.EditionPartition): GetAppConfigPath returns a filename
suffixed with the active edition name. Open-source keeps "app.json" for
backwards compatibility; sibling editions land on "app-<edition>.json".
LoadAppConfig / SaveAppConfig / HasAppConfig / DeleteAppConfig all
route through GetAppConfigPath, so this single change physically
isolates credential files end-to-end without any read-time heuristics.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(auth): address app config partition review

* fix(auth): clean legacy sibling app config

* test(auth): cover legacy app config cleanup guards

* fix(auth): close app config review follow-ups

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 17:43:20 +08:00
修雨 f497047fff fix(compat): relax envelope leaf cmd Args from NoArgs to ArbitraryArgs (#306)
NewDirectCommand was hard-coding cobra.NoArgs for envelope-generated
leaf commands that have no positional bindings (totalMax == 0). This
is stricter than cobra's own default — legacyArgs (args.go:30-32)
returns nil for any command without subcommands.

The strict behavior surfaced as "unknown command \"<word>\" for
\"dws aisearch person\"" whenever an AI agent passed trailing
positional words after a leaf, e.g.

  dws aisearch person search --keyword "张"
  dws aisearch person user search --keyword "张"

Switching to cobra.ArbitraryArgs restores cobra's natural leaf
behavior: trailing positional args are silently ignored. Existing
positional-binding paths (MinimumNArgs / RangeArgs / MaximumNArgs)
are unchanged.

Verified against dws-wukong/auto-test/cli_to_mcp/testcases on
aiapp / live / aisearch: 50/50 pass (was 48/50; the 2 remaining
failures were the F-class extra-positional-args tolerance cases
this commit fixes).
2026-05-17 17:23:17 +08:00
修雨 a9de7d3ca4 chore(readme): refresh community DingTalk group QR (#296)
* chore(readme): refresh community DingTalk group QR

Replace the external alicdn-hosted QR image with a repo-tracked one
(.github/assets/community-qr.png), so the README is self-contained and
not dependent on third-party CDN availability.

QR encodes an external (cross-org) DingTalk group "dws开源沟通群",
valid until 2027-05-14. Scan with DingTalk mobile to join.

* chore(readme): use alicdn-hosted QR image (no repo binary)

Drop the repo-tracked .github/assets/community-qr.png and reference the
new community group QR via the alicdn CDN URL instead, matching the
original README convention (external image, no binary asset in tree).

QR points to "dws 开源沟通群" (external/cross-org DingTalk group),
valid until 2027-05-14. Mobile scan to join.

* chore(readme): match prior QR image width (150px)
2026-05-15 13:56:54 +08:00
FuShu-Yang 1c200d883f fix(app): prevent command field from overwriting existing endpoint (#297)
* fix(app): prevent command field from overwriting existing endpoint

In AppendDynamicServer, when a plugin declares command != id, the command
endpoint is written unconditionally, overwriting any previously registered entry.

Fix: use first-writer-wins - only write if the key is not yet present.

id registration and dynamicProducts remain unconditional (unaffected).

* test(app): add regression test for command endpoint first-writer-wins guard
2026-05-15 11:29:07 +08:00
修雨 d268524084 docs(changelog): add 1.0.28 release notes (#295) 2026-05-14 21:34:28 +08:00
修雨 ed4673e7d2 fix(chat): require --title for group messages, completing #250's symmetric fix (#294)
`send_message_as_user`'s schema also marks `title` as required, but
`buildChatMessageSendInvocation` only pre-validated it for direct
messages. Sending `dws chat message send --group <cid> --text "1"`
without `--title` therefore reached the API and returned the same
misleading `发群服务窗会话消息失败` business error that #250 fixed for
direct messages, just on the other branch.

Pre-validation now covers both branches: group sends without title
return `--title is required for group messages (--group)`, direct sends
keep the existing `--title is required for direct messages (...)`
message. `Long` help, the `--title` flag description, the first
`Example` line, and `skills/references/products/chat.md` (including the
deeper "drive → chat" workflow example) are realigned to
"群聊与单聊都必填". `internal/helpers/chat_test.go` gains a
`group-without-title` case, and the existing `group` / `positional-text`
success cases are updated to pass `--title`. No API request shape change.
2026-05-14 21:13:28 +08:00
修雨 de723914a5 docs(changelog): add 1.0.27 release notes (#293)
* docs(changelog): add 1.0.27 release notes

Covers what landed on main since 1.0.26:

- #291: file_read transform + CLIFlagOverride.MapsTo field, and the
  envelope-side rollout that turns it into `dws doc update --content`
  / `--content-file` (literal vs file/stdin → markdown).
- envelope: `dws sheet find --query` hidden alias via the existing
  CLIFlagOverride.Aliases — keeps wukong-doc copy-paste working on
  open-source. Needs `dws cache refresh` once.
- #285: suppress noisy WARN on stdio client shutdown.

* docs(changelog): translate 1.0.27 entry to English
2026-05-14 15:50:45 +08:00
FuShu-Yang 649801e479 fix(transport): suppress noisy WARN on stdio client shutdown (#285)
* fix(transport): suppress noisy WARN on stdio client shutdown

When Stop() kills the subprocess, cmd.Wait() always returns a non-zero
exit code which is expected behavior. Previously this propagated as an
error, causing "failed to stop stdio client ... exit status 1" warnings
on every normal CLI exit for stdio-based plugins.

Now Stop() returns nil when the process was explicitly killed, keeping
the error path only for cases where the process exits on its own with
a non-zero code (e.g. stdin close without kill).

🤖 Generated with [Qoder][https://qoder.com]

* fix(transport): address review feedback — simplify Stop(), fix test assertion

- Remove redundant `killed` flag; inline Kill+Wait+return nil
- Update integration test to positively assert Stop() returns nil after kill

🤖 Generated with [Qoder][https://qoder.com]
2026-05-14 14:45:23 +08:00
修雨 49c5bea4f3 feat(schema): file_read transform + CLIFlagOverride.MapsTo for --content/--content-file (#277 #278 #282 #288) (#291)
* feat(transform): add file_read transform for UTF-8 file / stdin content (#277)

Introduces a new ApplyTransform case "file_read":
- Input: a string flag value treated as a UTF-8 file path
- Special case: the path "-" reads from stdin
- Output: the file contents as a string
- Errors surface as validation errors (exit 2) — non-UTF-8, missing
  file, empty path, and non-string input all reject cleanly

This is a foundational primitive intended to compose with envelope
schema features so a CLI flag like --content-file can carry a path
that ultimately feeds a string-typed MCP parameter with the file's
contents.

## Scope note — MapsTo intentionally NOT included

The original proposal in #277 paired this transform with a new
CLIFlagOverride.MapsTo field that retargets a flag's value to a
different MCP parameter (e.g. --content / --content-file both feed
the upstream `markdown` param). Pre-production end-to-end validation
(see #282) showed that the MCP registry server-side schema does not
currently recognise `mapsTo` and strips it on serialisation. Every
other new envelope field (hidden / required / transform /
mutuallyExclusive / requireOneOf) survives — only mapsTo is dropped.

Shipping MapsTo without server-side support would land dead client
code. The MapsTo field + sourceFlag transform guard were therefore
removed from this PR; they will return in a follow-up PR once #282
(server-side schema acknowledgement of mapsTo) is resolved. The
file_read transform stays here because it composes with multiple
mechanisms beyond MapsTo and is independently testable.

## Tests

Six new cases in internal/compat/transform_test.go cover the
contract: literal file, stdin via "-", missing file, non-UTF-8
input, empty path, non-string input. All pass under both `go test`
and `go test -coverprofile`.

Refs #277, blocked-by #282

* feat(schema): add CLIFlagOverride.MapsTo for sibling-flag routing (#277 #282)

Adds the `MapsTo` field on `CLIFlagOverride` and wires the dispatch loop
in `compat.buildOverrideBindings` so a flag's final value (post-transform
or literal) is routed into a different MCP parameter slot than its own
property name. This lets two sibling CLI flags feed a single upstream
parameter — the canonical case being `--content` (literal) + `--content-file`
(transform: file_read) both mapping to `markdown`.

Tool-level `CLIToolOverride.MutuallyExclusive` (cobra MarkFlagsMutuallyExclusive)
is the right partner for guarding "set one, not both" at parse time; no
new exclusion machinery is added.

Closes the client-side gap previously misattributed to a server-side
mapsTo strip in #282. Once a doc envelope with mapsTo lands in pre-prod,
end-to-end `--content-file` becomes shippable, finishing #277 Step 1b.

Test coverage (internal/compat/dynamic_commands_test.go):
  - MapsTo without transform: literal --content → params[markdown]
  - MapsTo with file_read transform: --content-file path → params[markdown]
  - Sibling flags both mapsTo same target, only one set: clean routing
  - Sibling flags both set: rejected by tool-level MutuallyExclusive (regression)

Backward-compat: empty MapsTo preserves existing params[propertyName] write
semantics for every existing envelope.
2026-05-14 14:44:00 +08:00
6707e56f9c feat(cli): schema-aware sticky flag splitting and structured unknown-flag recovery (#272)
* 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

* stick opt

* fix(cli): utf-8 safe sticky suffix guard + changelog (#272)

SuffixLooksLikeValue used to read suffix[0] (a single byte) for both the
uuid format branch and the fallback "is the first rune a letter?" check.
For multi-byte UTF-8 leading runes — common in dws because value text is
often Chinese — this picked up only the first byte (0xE0..0xF4 lead),
which is not a letter and not a hex digit, so the function silently
returned true and let glued tokens like --name<CJK> get split into
--name <CJK>... This switches both branches to utf8.DecodeRuneInString
and adds a utf8.RuneError guard so invalid UTF-8 input is rejected too.

Also locks down the new behaviour in CHANGELOG ## [Unreleased]:
- Changed: schema-aware sticky flag splitting
- Added: available_flags field on unknown-flag errors

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 10:56:02 +08:00
修雨 2ba1dcdda4 docs(changelog): add 1.0.26 release notes (#271)
Cover the 5 PRs merged since v1.0.25:
- #259 -f ndjson / -f csv + real-traffic preferredListKeys extension
- #250 chat send --title required for direct messages (pre-existing
  Unreleased entry preserved verbatim)
- #242 Windows PAT URL truncation fix via rundll32 opener
- #268 axls preflight on dws doc download
- #267 DWS_DISABLE_KEYCHAIN fallback for macOS sandbox

Also document the resolution of #240 (dws doc comment *
PARAM_ERROR - 未找到指定工具): fix is in the market metadata
(`serverOverride: doc-comment` on the four comment toolOverrides)
rather than in CLI code, so existing users need to run
`dws cache refresh` once. Verified post-refresh that dry-run
resolves to the doc-comment MCP server endpoint and real calls
return normal business responses instead of the PARAM_ERROR.
2026-05-12 16:40:34 +08:00
修雨 1637ae16c7 fix(keychain): add DWS_DISABLE_KEYCHAIN fallback for macOS sandbox (#214) (#267)
In sandboxed macOS runtimes (e.g. Codex App), `security` / Keychain APIs
are blocked, so `keyring.Get`/`Set` for the DEK fails on every token
read/write. Add an opt-in env var that switches the macOS implementation
to the same file-based DEK scheme already used on Linux. Default
behavior is unchanged.

- Extract shared `fileDEK(service)` into `file_dek.go` (darwin || linux)
- Linux `getDEK` now delegates to `fileDEK`
- Darwin `getDEK` short-circuits to `fileDEK` when DWS_DISABLE_KEYCHAIN=1
- Document the tradeoff in reference.md (DEK and ciphertext co-located)
- Add darwin-only tests covering fallback path + overwrite
2026-05-12 14:57:00 +08:00
xuanandshangguanxuan.sgx eee19d7347 fix(pat): preserve auth link on Windows browser open (#242)
* fix(pat): preserve auth link on Windows browser open

* fix(pat): expose copy-safe authorization URL

* test(pat): preserve extra authorization route params

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-05-12 14:55:59 +08:00
xuanandshangguanxuan.sgx 19f7b59ffb fix(doc): preflight axls download (#268)
Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-05-12 11:48:31 +08:00
wxianfeng aa06d9d5c9 Merge remote-tracking branch 'upstream/main' 2026-05-12 00:06:48 +08:00
修雨 c4952d0207 feat(output): add -f ndjson and -f csv (#252) (#259)
* feat(output): add -f ndjson (implemented) and scaffold -f csv (#252)

`larksuite/cli` exposes --format ndjson / csv; dws only had json/table/raw/
pretty. This adds both as recognised global formats:

- ndjson: fully implemented (internal/output/ndjson.go) — top-level arrays
  and well-known list wrappers ({items|results|data|records|...}) emit one
  compact JSON document per line; anything else degrades to a single line.
  Streaming-friendly counterpart to `-f json`.
- csv: scaffolded (internal/output/csv.go) — writeCSV currently returns a
  clear "not implemented (#252)" error rather than silently degrading to
  JSON. A TODO block spells out the planned implementation (reuse the table
  renderer's extractRowsFromMap/rowsFromSlice flattening + encoding/csv).

Wired through Write() and normalizeFormat(); --format help string now lists
ndjson (csv to be added when writeCSV lands). Tests cover ndjson rendering
(array / wrapped-list / scalar) and pin the csv not-implemented contract.

Skeleton PR for #252 — ndjson is shippable as-is; csv is left for a
follow-up commit on this branch.

* feat(output): implement -f csv (#252)

Completes the CSV half of the format work. writeCSV mirrors the shape
decisions `-f table` already makes (reuses normalizePayload /
unwrapPrimaryObject / extractRowsFromMap / rowsFromSlice / formatValue) so
columns and value flattening are consistent between the two formats:

- a list of objects (bare or wrapped under items/results/data/records/...) →
  header row + one row per element; union of keys sorted; missing values are
  empty cells; nested objects/arrays render as compact JSON in the cell;
  sibling metadata of the list (total, hasMore, ...) is dropped.
- a single object → two-column key,value CSV (keys sorted).
- a non-uniform list / scalar → single-column `value` CSV.
- empty / nil → empty document.

encoding/csv.Writer handles RFC-4180 quoting (commas, quotes, newlines);
cells go through formatValue (also strips terminal control sequences, same
as the table renderer). `--fields` projection composes for free since
WriteFiltered applies SelectFields before Write.

--format help now lists csv; FormatCSV doc comment dropped the WIP marker;
the not-implemented test is replaced with real coverage (list with
comma/CJK/nested-array-as-JSON, wrapped-list-with-metadata, single object,
scalar) plus a --fields composition test.

* feat(output): broadcast list metadata as trailing columns in -f csv (#252)

Per review preference: instead of dropping the list's sibling metadata
(total, hasMore, ...) when rendering {records:[...], total:N} as CSV, append
each meta key as a trailing column repeated on every row, so a CSV consumer
never silently loses it (CSV has no "footer table" the way the table
renderer does). Meta keys colliding with a data column are skipped; an empty
list still emits the header (data + meta) plus one row of empty data cells
carrying the meta values. New broadcastMeta helper; doc comment + tests
updated (incl. an empty-list-with-metadata case).

* feat(output): recognise real DingTalk envelope keys in -f csv/ndjson/table

之前 -f csv 和 -f ndjson 的 list 检测白名单只认 items/results/data/list/
records/tools/servers/products,但真实钉钉响应用的是 result(单数直接数组
或一层包裹)/documents/emailAccounts/todoCards/events/messages,导致大
部分 list 命令的 csv 输出退化成 key,value 二列、ndjson 退化成整包一行。

修复点:

1. preferredListKeys 扩展加上真实 envelope key(result/documents/
   emailAccounts/todoCards/events/messages),并把它升级为 csv/table/
   ndjson 共享的"单一事实源"——filter.go 的 findDataList 不再维护自己
   的本地副本,直接复用这个列表。
2. extractRowsFromMap 改为委托 findDataList,自动获得"一层深度"的
   wrapper 支持({result: {todoCards: [...]}} 这种 envelope 现在能识
   别)。meta 合并:outer + inner 双层 sibling 拉通,outer 同名 key
   优先(避免 inner 把外层 success/total 等覆盖掉)。
3. writeTableish 和 writeCSV 调整 unwrapPrimaryObject 和
   extractRowsFromMap 的优先级——先试 list 检测,没命中再走 unwrap,
   避免 {result: {todoCards: [...]}} 被 unwrap 剥掉外层后直接走
   key,value 分支。
4. findDataList 允许"空数组+preferred key" 命中,保留原来"空 list +
   metadata 仍渲染为表格 + meta 广播一行"的行为。

新增 TestTabularDetectsRealDingTalkEnvelopes:四个真实 envelope 形态
(contact user search 的 result 直接数组、doc search 的 documents 顶层、
mail mailbox list 的 emailAccounts、todo task list 的 result.todoCards
一层深度)验证 ndjson 行数和 csv 表头都符合预期。

真接口回归(已登录态跑过):
- dws contact user search -f csv  → name/userId 列正常出表
- dws todo task list -f ndjson    → 20 行一条任务,可 jq -r .subject 直接管
- dws doc search -f csv           → 10 行 + nextPageToken 等 meta 广播尾列
- dws schema -f csv               → 无回归

Closes part of #252 follow-up.
2026-05-11 22:15:59 +08:00
修雨 ecf2684f58 docs(skills): add sheet product reference rewritten against dws schema (#266)
The `sheet` (在线电子表格) product registers **34 envelope tools** that
have been live for a while, but `skills/references/products/sheet.md`
was never added, and `skills/SKILL.md` 产品总览 didn't list `sheet`.
Agents had no per-command reference and would skip the product during
intent routing. This PR closes the gap with a doc **written against
the actual envelope state**, not copied from a downstream draft.

Process (different from prior #264, which was withdrawn for citing
phantom commands):

1. `dws schema | jq '.products[] | select(.id=="sheet")'` to enumerate
   the 34 real tools, with `required` and `flag_overlay` per tool.
2. Wrote sheet.md grouped by function: worksheet / range / dimension /
   merge / find-replace / filter-view (named views) / filter (sheet-
   level) / image / export. Each section lists tools with their
   canonical_path and cli_name as-they-actually-exist.
3. Documented v1.0.25 reality on naming: about a third of `sheet`
   tools still expose snake_case cli_names (`copy_sheet`,
   `submit_export_job`, `set_filter_criteria`, etc.) pending the
   `CLIAliases` (#246) rollout. Mixed style is called out at the top.
4. Documented the export reality: v1.0.25 envelope exposes only the
   atomic `submit_export_job` + `query_export_job`. There is **no
   consolidated `dws sheet export`** — Pipeline (#247) is the future
   plumbing for that. Doc walks through the two-step manual flow.
5. Verified before commit: every `dws sheet ...` reference in sheet.md
   maps to one of the 34 envelope cli paths. Zero phantom commands.
   Zero cross-repo `../url-patterns.md` style relative links.

Verification command:

  python3 verify.py  # set-diff sheet.md refs against `dws schema sheet`
  # → md covers 34/34 envelope cli paths; the only "extras" are
  #   `dws sheet export` and `dws sheet filter-view` mentioned
  #   purely as disambiguation/group prefix references.

Files:

- skills/references/products/sheet.md (new, 304 lines) — compact
  but complete: 命令命名风格说明 + 8 functional groups + common
  usage examples + 易混淆点 + 危险操作 + 何时不要用 sheet +
  权威参考命令.
- skills/SKILL.md — adds `sheet` row to 产品总览 table, adds an
  intent-routing line (在线电子表格/axls/工作表/单元格读写/合并
  单元格/筛选视图/导出 xlsx → `sheet`), extends frontmatter
  `description` to include 在线电子表格 (axls).
- CHANGELOG.md — extends v1.0.25 `### Added` with an entry that
  honestly describes both the 34 tools shipping and the v1.0.25
  caveats (mixed cli_name style + no consolidated export).
- README.md / README_zh.md — adds a Sheet row (34 cmds) to "Key
  Services" with full subcommand inventory; updates the total to
  "197 commands across 15 products" (was 163 / 14). `wiki` is
  intentionally untouched — it ships separately in PR #265.

Scope note: this PR intentionally does NOT bundle `wiki` —
PR #265 ships `wiki` independently because the prior combined
attempt #264 made review harder. Splitting keeps each PR
verifiable as a unit.
2026-05-11 16:54:07 +08:00
修雨 9e9b898dd2 docs(skills): add wiki product reference + register in SKILL.md/README (#265)
The `wiki` (知识库) product registers 7 envelope tools — `wiki.create_wikiSpace`,
`wiki.get_wikiSpace`, `wiki.list_wikiSpaces`, `wiki.search_wikiSpaces`, plus
`wiki.add_member` / `list_member` / `update_member` — surfacing as
`dws wiki space {create,get,list,search}` and `dws wiki member {add,list,update}`.
They've been registered for a while, but `skills/references/products/wiki.md`
was never added and `skills/SKILL.md` 产品总览 didn't list `wiki`, so agents
had no per-command reference to read and would skip it during intent routing.

Verified before commit: every `dws wiki ...` reference inside wiki.md
matches a `cli_name` from `dws schema` output (7/7).

Files:

- skills/references/products/wiki.md (new, 177 lines) — full command
  reference: space create / get / list / search + member add / list /
  update. Style matches existing `chat.md` / `aitable.md`. No cross-repo
  links (verified: 0 external relative refs).
- skills/SKILL.md — adds `wiki` row to 产品总览 table, adds an
  intent-routing line ("知识库 / wiki / 团队空间 / 知识库成员管理" → `wiki`),
  extends frontmatter `description` to include 知识库.
- CHANGELOG.md — v1.0.25 ### Added gains an entry explaining that the
  wiki envelope tools were already registered but the skill reference
  hadn't shipped; this release closes that doc gap.
- README.md / README_zh.md — adds a "Wiki" / "知识库" row to "Key Services"
  (7 cmds, subcommand groups `space` `member`), updates the total to
  "170 commands across 15 products" (was 163 / 14), removes `wiki` from
  the "Coming soon" callouts.

Scope note: this PR intentionally does NOT touch `sheet` — the prior
PR #264 was withdrawn after envelope verification showed several
sheet commands (`dws sheet export`, `media-upload`, `filter-view
set-criteria` / `clear-criteria`, `range get`) referenced in the
downstream draft don't actually exist in the v1.0.25 envelope. A
separate sheet PR will follow after a full rewrite against
`dws schema sheet`.
2026-05-11 16:21:53 +08:00
修雨andClaude Opus 4.7 17f692e7f1 fix(chat): require --title for direct messages, fix misleading help (#250)
* fix(chat): require --title for direct messages, fix misleading help

`dws chat message send --user <id> --text ...` (and the --open-dingtalk-id
variant) failed at the API layer with the cryptic "发群服务窗会话消息失败"
when --title was omitted, because send_direct_message_as_user requires a
title at the business layer. The CLI advertised the opposite: the --title
flag description said "可选" (optional) and one help example sent a direct
message without it.

- Validate --title up front for --user / --open-dingtalk-id sends:
  "--title is required for direct messages (--user / --open-dingtalk-id)".
  Group messages are unchanged (title stays optional there).
- Fix the long help, the --title flag description, the help examples, and
  skills/references/products/chat.md to say title is required for direct
  messages, optional for group messages.
- Tests: add --title to the direct-message routing cases that now require
  it, and add rejection cases for direct sends without --title.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(changelog): add Unreleased entry for #250 chat send --title fix

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:21:41 +08:00
修雨 574d9aa2f7 docs(changelog): backfill 1.0.24 + add 1.0.25 release notes (#262)
- 1.0.24 (tag already pushed at 9e15115 on 2026-05-09): #248 embedded
  self-upgrade guard, #238 auth login help realignment, #261 release
  workflow_dispatch fallback.
- 1.0.25 (next release): #246 CLIAliases tool override + json_parse_strict
  transform, #247 Pipeline tool override + executor for multi-step
  workflows (submit → poll → download).
2026-05-11 13:56:12 +08:00
修雨 1aaaef0274 feat(schema): add CLIAliases tool override + json_parse_strict transform (#246)
Two generic CLI envelope schema enhancements that close gaps surfaced by
the cli_to_mcp test suite without resorting to product-specific helpers:

1. CLIToolOverride.CLIAliases (registry.go + dynamic_commands.go)
   - Lets a single MCP tool register additional cobra command aliases via
     envelope JSON (e.g. `range read` accepts `range get`, `member list`
     accepts `member ls`). Plumbs through the existing Route.Aliases ->
     cobra.Command.Aliases path; conflicts with siblings are silently
     skipped by cobra.

2. json_parse_strict transform (transform.go)
   - Strict JSON variant of json_parse that does NOT fall back to YAML.
     Use when the upstream tool requires a structured array/object value
     and silently coercing malformed input to a scalar string would mask
     a real user error (observed: filter-view --criteria 'NOT_VALID_JSON'
     was being accepted and quietly creating an empty-criteria view).
2026-05-11 10:56:19 +08:00
修雨 00c037b5be feat(schema): add Pipeline tool override + executor for multi-step workflows (#247)
A generic envelope schema feature that lets a single CLI command orchestrate
an ordered sequence of MCP tool calls plus optional HTTP-download sinks,
declared entirely in the envelope JSON. The motivating use case is the
"submit-job + poll-status + download-result" pattern (e.g. sheet export),
which previously required hardcoded helper commands per product.

## Schema additions

- `CLIToolOverride.Pipeline []PipelineStep` — when non-empty, dispatch
  ignores the parent map key (no single "primary tool") and walks the
  steps in order. CLI surface (CLIName / Group / Flags) still applies.
- `PipelineStep` struct — supports two step types:
  - `type:"call"` (default) invokes Tool with templated Args. Optional
    PollUntilField/Value turn it into a polling loop with configurable
    PollIntervalSec / PollTimeoutSec.
  - `type:"download"` resolves DownloadURLField, fetches via HTTP GET,
    and writes the body to the path supplied by OutputFlag's value
    (with directory-path filename inference).
- `CLIFlagOverride.PipelineLocal bool` — marks a flag as CLI-side only;
  CollectBindings skips it so the value never reaches MCP params, but
  the pipeline executor reads it via extractFlagValuesByAlias.

## Template language

Two prefixes supported in PipelineStep.Args / DownloadURLField:

  $flag.<name>           — value of the user's CLI flag whose alias is <name>
  $step.<idx>.<dotPath>  — field from a prior step's response (idx 0-based)
  literal                — passed through

dotPath walks nested map[string]any so "$step.1.content.downloadUrl"
resolves through wrapped MCP envelopes correctly.

## Stdout contract

The download step always emits machine-parseable plain-text lines
("jobId: <id>\\n", "downloadUrl: <url>\\n") in addition to the standard
JSON output, so shell pipelines and regex-based test suites can extract
key values without parsing JSON. Structured callers continue to consume
the output.WriteCommandPayload JSON envelope.

## Why this is schema enhancement, not product hardcode

- The executor is product-agnostic: any envelope can declare a Pipeline
  and benefit (sheet export today; doc/drive/aitable async patterns
  tomorrow).
- The template language is the only product-coupling, and it lives in
  the envelope JSON — not in Go code.
- No sheet/wiki/aitable-specific code in dingtalk-workspace-cli.

Files: 1 new + 3 modified (~250 LOC of executor + ~30 LOC of schema
plumbing). Existing tests pass; new pipeline tests TBA in a follow-up
once the schema fields are in upstream.
2026-05-11 10:56:10 +08:00
修雨 9e15115ad4 ci(release): add workflow_dispatch trigger as fallback (#261)
GitHub occasionally drops tag push events; this adds a manual trigger
so we can re-run the release job against any tag ref without having to
delete and re-push the tag.
2026-05-11 09:53:23 +08:00
xianfeng wangandgithub-actions[bot] 25bf3d12f2 feat(upgrade): block self-upgrade in embedded distributions (#248)
* 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

* upgrade not in embed

* remove comment

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-09 20:27:20 +08:00
wxianfeng bb3d1270b9 Merge remote-tracking branch 'upstream/main' 2026-05-09 11:49:26 +08:00
修雨 f78cc5c846 docs(auth): correct login help to reflect actual default + SSH guidance (#226) (#238)
* docs(auth): correct login help to reflect actual default + SSH guidance (#226)

The --help long description for `dws auth login` claimed the default mode was
"OAuth 设备流 (默认)", but the actual default starts a 127.0.0.1 loopback
listener (oauth_provider.go:131-136) and only switches to device flow when
--device is passed. SSH-into-headless-Linux users following the docs hit a
dead end because the local browser cannot reach 127.0.0.1 on the remote box.

Rewrite the description so each method is named after its real flag:
  - OAuth Loopback 流 (默认)
  - OAuth 设备流 (--device)
  - 直接提供 Token (--token)

Add an explicit warning callout and a `--device` example for SSH/headless
environments. Also realign two flagErrorWithSuggestions strings in root.go
that propagated the same misconception ("默认使用设备流" → loopback default,
SSH 用户加 --device).

No behavioural change.

* docs(auth): drop emoji from login help warning
2026-05-09 09:25:57 +08:00
修雨 72fe795f3f docs(changelog): add 1.0.23 release notes (#241)
Backfill 1.0.23 entry covering PR #237 (HTTP_PROXY/HTTPS_PROXY support
restored across the three custom http.Transport instances). Format
follows the 1.0.22 section: narrative + Fixed + Tests.
2026-05-08 20:41:11 +08:00
修雨 0e892c7d75 fix: honour HTTP_PROXY/HTTPS_PROXY in custom HTTP transports (#236) (#237)
The three custom http.Transport instances built by the CLI
(`internal/transport/client.go` MCP transport, `internal/apiclient/client.go`
DingTalk OpenAPI client, `internal/app/legacy.go` IPv4-forcing registry
client) all set DialContext / TLSClientConfig / timeouts but omit the Proxy
field. Per Go's net/http contract, a non-nil Transport without an explicit
Proxy means "no proxy" — env vars are silently ignored, breaking sandboxed
or air-gapped deployments that route outbound through HTTP_PROXY /
HTTPS_PROXY.

Set Proxy: http.ProxyFromEnvironment on all three. Adds a regression test
per package that pointer-compares the Transport's Proxy func against
http.ProxyFromEnvironment (avoids flakiness from Go's envProxyOnce
memoisation when running alongside tests that read proxy env early).
2026-05-08 16:07:14 +08:00
修雨 8995bf65d6 docs(changelog): backfill 1.0.20 / 1.0.21 / 1.0.22 release notes (#231) 2026-05-07 21:52:09 +08:00
wxianfeng 7d40210a00 Merge remote-tracking branch 'upstream/main' 2026-05-07 16:45:19 +08:00
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
wxianfeng ab445813b7 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-04-30 10:20:38 +08:00
wxianfeng 631a3829e4 1.0.19 changelog 2026-04-30 10:20:17 +08:00
github-actions[bot] 88b4f4eeaa chore: update coverage badge [skip ci] 2026-04-30 02:17:55 +00:00
wxianfeng ee8af0c7d0 merge upstream main and fix conflict 2026-04-30 10:15:37 +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
github-actions[bot] 4f3501e904 chore: update coverage badge [skip ci] 2026-04-27 16:55:32 +00:00
wxianfeng b0108b9c21 merge upstream main 2026-04-28 00:53:11 +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
wxianfeng 4dc79fc931 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-04-26 11:24:22 +08:00
wxianfeng 130387eb36 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-04-24 21:04:30 +08:00
github-actions[bot] 0c2b2b4703 chore: update coverage badge [skip ci] 2026-04-24 13:04:24 +00:00
xianfeng wang fed83761e5 Merge branch 'DingTalk-Real-AI:main' into main 2026-04-24 21:02:19 +08:00
wxianfeng 55eb481899 Merge remote-tracking branch 'upstream/main' 2026-04-24 20:55:47 +08:00
Ari b0c9160ed5 fix(chat,cmdutil): route chat message send by destination flag (#170)
* fix(app): graft hardcoded helpers into dynamic tree instead of dropping

pickCommands previously dropped an entire helper subtree whenever a
top-level product name collided with the dynamic overlay. The intent was
to keep the discovery envelope as runtime authority for leaves it
declared, but the side effect was that every helper-only sibling
disappeared too — commands like `chat message send-by-bot`,
`chat message recall-by-bot`, `chat message send-by-webhook`, and
`chat group members add-bot` had silently vanished from the open
edition since the pickCommands introduction, making PR #161's
send-by-bot routing fix unreachable for open-source users.

Switch to cmdutil.MergeHardcodedLeaves for same-named products: the
dynamic side still wins every leaf conflict, and helper-only subtrees
are grafted into the dynamic tree. Envelopes remain authoritative for
leaves they declare; helpers once again fill the gaps they don't.

Verified on a freshly built open-edition binary:
  dws chat message send-by-bot --robot-code ... --users ... --text ...
returns a real processQueryKey and routes through canonical_product=bot
via helper_override, matching the v1.0.15 baseline.

* fix(chat,cmdutil): route chat message send by destination flag

`dws chat message send --user <userId>` failed with `不合法的参数` because
the envelope-generated dynamic command maps every destination to the
group-only tool `send_message_as_user`. Only `--group` was viable on the
open edition; single-chat (`--user`, `--open-dingtalk-id`) has never
worked end-to-end.

Fix is in two parts:

1. Add a hardcoded `chat message send` helper that dispatches by
   destination flag — `--group` → `send_message_as_user`, `--user` /
   `--open-dingtalk-id` → `send_direct_message_as_user`. This mirrors the
   closed-source wukong overlay's chatMessageSendCmd so the open edition
   lines up with the rest of the product.

2. Let hardcoded leaves opt into overriding the dynamic envelope in
   `cmdutil.MergeHardcodedLeaves` when they carry a strictly higher
   OverridePriority. The default remains "envelope is authority"; the
   opt-in exists for the narrow case where the envelope exposes a single
   dispatch path but the hardcoded leaf needs richer flag-based routing
   (as here). The new send helper uses preferLegacyLeaf (priority 100)
   so it wins against the dynamic leaf.

OverridePriority/SetOverridePriority move to pkg/cmdutil as the new
source of truth; internal/cobracmd/priority.go now delegates so the
annotation key stays single-sourced across the merge layer and helpers.

Verified end-to-end against a freshly built open-edition binary:
  send --user 034766 --text …  → send_direct_message_as_user → success
  send --group <cid> --text …  → send_message_as_user          → success
2026-04-24 20:41:30 +08:00
Ari 0b544a86b7 fix(app): graft hardcoded helpers into dynamic tree instead of dropping (#169)
pickCommands previously dropped an entire helper subtree whenever a
top-level product name collided with the dynamic overlay. The intent was
to keep the discovery envelope as runtime authority for leaves it
declared, but the side effect was that every helper-only sibling
disappeared too — commands like `chat message send-by-bot`,
`chat message recall-by-bot`, `chat message send-by-webhook`, and
`chat group members add-bot` had silently vanished from the open
edition since the pickCommands introduction, making PR #161's
send-by-bot routing fix unreachable for open-source users.

Switch to cmdutil.MergeHardcodedLeaves for same-named products: the
dynamic side still wins every leaf conflict, and helper-only subtrees
are grafted into the dynamic tree. Envelopes remain authoritative for
leaves they declare; helpers once again fill the gaps they don't.

Verified on a freshly built open-edition binary:
  dws chat message send-by-bot --robot-code ... --users ... --text ...
returns a real processQueryKey and routes through canonical_product=bot
via helper_override, matching the v1.0.15 baseline.
2026-04-24 20:32:05 +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
github-actions[bot] eb3eecab1d chore: update coverage badge [skip ci] 2026-04-24 06:51:58 +00:00
wxianfeng 3afbc046d8 Merge remote-tracking branch 'upstream/main' 2026-04-24 14:48:43 +08:00
ybc❤️zyrand猷诺 ad33a46883 fix: device flow defensively resets credentials before login (#155) (#157)
Device flow now always clears stale credential state and re-fetches
clientID from MCP server, regardless of what previous login methods
(OAuth scan, PAT, etc.) left in app.json or runtime globals.

This replaces the previous Source-field approach with a simpler
defensive reset that is future-proof against new login methods.

Root cause: after OAuth login saved app.json without MCP source marker,
subsequent --device login reused the stale clientID without setting
clientIDFromMCP flag, causing exchangeCode() to use direct mode which
requires clientSecret.

Changes:
- Add resetCredentialState() to DeviceFlowProvider
- Login() always calls resetCredentialState() + FetchClientIDFromMCP()
- Remove AppConfig.Source field (no longer needed)
- Remove app_config_source_test.go (replaced by device_flow_reset_test.go)
- Add 5 tests covering OAuth→device, legacy→device, direct→device scenarios

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-04-24 14:11:16 +08:00
xianfeng wang bbca507792 feat(discovery): MCP discovery service abstraction with positional params, examples, defaults, and schema v3 extensions (#156)
* discovery

* discovery

* wk discovery

* wk discovery

* wk discovery

* wk discovery

* wk discovery

* wk discovery

* discovery example and default

* position param

* Fix test

* dev auto login

* fix test

* fix conflict
2026-04-24 14:08:17 +08:00
Ari 97390829dd fix(compat,helpers): json_parse YAML fallback + send-by-bot product routing (#161)
* fix(compat): accept YAML flow as a fallback for json_parse transform

Users who run `dws aitable table create --fields [{fieldName: 标题, ...}]`
hit a shell glob error (`zsh: bad pattern: [{fieldName:`) because the
unquoted brackets are interpreted as a zsh pattern. Even when the shell
is placated with outer quotes, the strict-JSON parser still rejected the
natural form that drops quotes around keys and string values.

transformJSONParse now tries strict JSON first (unchanged fast path) and,
on failure, falls back to yaml.Unmarshal. YAML's flow syntax is a
superset of JSON that accepts `{key: value}` without surrounding quotes,
so a single set of outer quotes is all the user needs:

  dws aitable table create --fields '[{fieldName: 标题, type: text}]'

Already-valid strict JSON is unaffected. The @file syntax (`--fields
@path.json`) continues to work through the same transform.

The terminal error message, if both parsers fail, now points users at
the two working forms (quoted YAML flow or @file).

* fix(helpers): route chat send-by-bot to bot product

`chat message send-by-bot` previously stamped its invocation with
CanonicalProduct="chat", routing it through the chat product's auth /
server dispatch. The command is semantically a bot operation — switch
it to "bot" so auth client selection and downstream routing match the
actual MCP server. Extends chat_test.go with single-chat and group-chat
routing assertions.
2026-04-24 01:36:32 +08:00
Ari 7828089081 feat: subcommand merging, schema pretty, IR overlays, v1.0.15 notes (#160)
* feat(compat): merge same-name subcommands under shared parent

When multiple server entries attach to the same parent and their cli.command
collides with a subcommand already in the parent tree, the incoming subcommand's
children are merged recursively into the existing one instead of producing a
duplicate sibling. Leaf-name collisions resolve first-wins.

Fixes the duplicate `group` / `message` rows in `dws chat --help` that surfaced
when bot capabilities were distributed across chat.group.members and
chat.message subtrees.

* feat(ir): carry FlagOverlay and ToolAnnotations into canonical catalog

FlagOverlay mirrors the per-parameter CLI overlay (alias, transform,
transformArgs, env default, default value, hidden) sourced from
market.CLIToolOverride.Flags.

ToolAnnotations mirrors MCP 2025+ tool annotations with nullable hints
(destructive / read-only / idempotent / open-world) so absence means
"unknown" rather than "false".

BuildCatalog now propagates both through the canonical layer, with tests
covering group metadata and flag-overlay passthrough.

* feat(output): add -f pretty for schema-aware colored output

dws schema now supports -f pretty alongside json / raw. Pretty mode
partitions output by product / tool / parameter / enum and applies ANSI
color so humans can browse the catalog without piping through jq.
json / raw behaviour is unchanged.

docs/reference.md gains a Schema Introspection section documenting
dws schema and the new pretty format.

* feat(skills): refresh 13-product references; add devdoc / drive / oa

SKILL.md cli_version bumped to >=1.0.15; product index reordered to match
the v1.0.15 command surface.

New references:
- devdoc.md: Open Platform documentation search
- drive.md: DingTalk drive (promoted out of Coming soon)
- oa.md: OA approval end-to-end flow

Major rewrites for aitable (dashboard / chart share, import/export,
attachment flow), chat (bot capabilities merged into chat.message and
chat.group.members), calendar (event suggest / attachments), doc (comment
subtree, file create, upload/download flow), and minutes (list
mine/shared/all unified, record subcommands split).

Minor alignment touch-ups for contact / report / todo flag names.

* docs(release): sync v1.0.15 notes — 159 commands across 13 products

README Key Services fully refreshed: chat 23, calendar 14, aitable 41,
doc 21, minutes 19; drive promoted out of Coming soon; workbench and
standalone bot rows removed; Quick Start expanded to 7 examples spanning
doc / minutes / drive; Coming soon trimmed to mail / conference / aiapp /
live / wiki.

Adds docs/command-index.md, an auto-generated English listing of all 159
runtime commands with description and when-to-use guidance aimed at AI
agents. Replaces the ad-hoc command-index.pre.* / command-index.full.*
snapshots used during development.

CHANGELOG 1.0.15 records the compat subcommand-merging feature, the new
command index, and a flag-naming cleanup across chat / calendar / drive /
minutes / contact / devdoc.
2026-04-24 00:01:22 +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] 738b64eaf4 chore: update coverage badge [skip ci] 2026-04-23 03:30:15 +00: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
662 changed files with 147428 additions and 5034 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><filter id="blur"><feGaussianBlur in="SourceGraphic" stdDeviation="16"/></filter><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="#dd4343"/><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=".80" filter="url(#blur)" transform="scale(.1)" textLength="510">coverage</text><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=".80" filter="url(#blur)" transform="scale(.1)" textLength="370">54.2%</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.4 KiB

+75
View File
@@ -0,0 +1,75 @@
# 把本仓库代码自动镜像到 Gitee,供国内用户访问(raw 脚本入口 + tags)。
# 用 HTTPS + 令牌直接 git push(无需 SSH key),复用已配置的 secret:
# GITEE_TOKEN —— Gitee 私人令牌(勾 projects)
# GITEE_USER —— 令牌所属 Gitee 用户名(用于 https 推送鉴权)
# GITEE_REPO —— "owner/repo",如 DingTalk-Real-AI/dingtalk-workspace-cli
# 未配置 GITEE_TOKEN 时(如 fork)自动跳过,不报红叉。
name: Mirror code to Gitee
on:
push:
branches:
- main
tags:
- 'v*'
schedule:
- cron: '0 18 * * *'
workflow_dispatch:
jobs:
mirror:
runs-on: ubuntu-latest
# GitHub Actions 不允许在 job-level if 直接引用 secrets,故先用 env 暴露再在 step 守卫。
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
steps:
- name: Checkout (full history + tags)
if: env.GITEE_TOKEN != ''
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Push main + tags to Gitee (with README localization)
if: env.GITEE_TOKEN != ''
run: |
set -eu
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
# Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。
# GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。
git checkout -B gitee-main origin/main
git config user.email "actions@github.com"
git config user.name "github-actions[bot]"
# 1) 安装命令本地化:raw.githubusercontent → gitee raw(国内可达)。
for f in README.md README_zh.md; do
[ -f "$f" ] || continue
sed -i "s#raw.githubusercontent.com/${GITEE_REPO}/main#gitee.com/${GITEE_REPO}/raw/main#g" "$f"
done
# 2) coverage 徽章:仓库内相对路径 svg 在 Gitee 渲染不出来(gitee raw 对 svg
# 返回需签名、会过期的 URL,且 content-type 为 text/plain)。改成 shields.io
# 静态徽章——数值取自仓库 coverage.svg,颜色按覆盖率阈值。
SVG=".github/badges/coverage.svg"
if [ -f "$SVG" ]; then
PCT="$(grep -oE '[0-9]+(\.[0-9]+)?%' "$SVG" | head -1)"
NUM="${PCT%\%}"; INT="${NUM%.*}"
if [ "${INT:-0}" -ge 80 ]; then C=brightgreen; elif [ "${INT:-0}" -ge 60 ]; then C=yellow; else C=red; fi
BADGE="https://img.shields.io/badge/coverage-${NUM}%25-${C}"
for f in README.md README_zh.md; do
[ -f "$f" ] || continue
sed -i "s#\.github/badges/coverage\.svg#${BADGE}#g" "$f"
done
fi
git add README.md README_zh.md 2>/dev/null || true
git commit -m "docs(gitee): localize install commands + coverage badge for Gitee mirror" || true
# 镜像对齐(force:Gitee 始终跟随 GitHub + Gitee 专属 README 本地化)
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
git push --force --tags "$REMOTE"
echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}"
+54
View File
@@ -0,0 +1,54 @@
name: Multi Profile E2E
on:
pull_request:
push:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: multi-profile-e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
multi-profile-e2e:
name: Multi Profile E2E
runs-on: ubuntu-latest
timeout-minutes: 15
env:
MULTI_PROFILE_E2E_LOG: .tmp-bin/multi-profile-e2e.log
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run isolated multi-profile chain
shell: bash
run: |
set -o pipefail
mkdir -p .tmp-bin
bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir | tee "$MULTI_PROFILE_E2E_LOG"
{
echo "### Multi Profile E2E"
echo "- Command: \`bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir\`"
echo "- Scope: isolated auth/profile storage, profile switch/use, one-shot profile override, CSV multi-profile aggregation, legacy migration"
echo "- Result: passed"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload debug artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: multi-profile-e2e-debug
path: |
.tmp-bin/multi-profile-e2e.*/out
.tmp-bin/multi-profile-e2e.log
if-no-files-found: ignore
retention-days: 3
+43 -1
View File
@@ -4,6 +4,7 @@ on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: write
@@ -11,7 +12,10 @@ permissions:
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 30
# 60 (not 30): mirroring every release asset to Gitee is slow; 30 min cut the
# Gitee step off mid-upload on the v1.0.42 release. The Gitee step is now also
# idempotent (re-runs only upload missing assets).
timeout-minutes: 60
steps:
- name: Check out repository
@@ -27,6 +31,20 @@ jobs:
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Multi Profile E2E
run: bash scripts/dev/test-multi-profile-e2e.sh
- name: Install rcodesign (ad-hoc sign darwin binaries from Linux)
run: |
set -eu
RCS_VERSION="0.27.0"
curl -fsSL -o /tmp/rcodesign.tar.gz \
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz"
mkdir -p /tmp/rcodesign
tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rcodesign --strip-components=1
sudo install -m 0755 /tmp/rcodesign/rcodesign /usr/local/bin/rcodesign
rcodesign --version
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
@@ -46,6 +64,28 @@ jobs:
run: |
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
- name: Sync release to China OSS mirror
# 自动同步到国内镜像,供 install.sh 的 DWS_RELEASE_BASE 开关消费。
# 脚本自带门控:未配置 OSS_* secret 时优雅跳过,不影响海外发布。
run: ./scripts/release/sync-to-oss.sh
env:
VERSION: ${{ github.ref_name }}
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
- name: Mirror release to Gitee (China)
# 把 release 附件(二进制/校验和/skills 包)镜像到 Gitee release,供 install.sh
# 的 DWS_GITEE_REPO 开关消费(仓库代码由 Gitee 仓库镜像功能自动同步,附件不在其内)。
# 脚本自带门控:未配置 GITEE_TOKEN / GITEE_REPO 时优雅跳过,不影响海外发布。
run: ./scripts/release/sync-to-gitee.sh
env:
VERSION: ${{ github.ref_name }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -53,6 +93,8 @@ jobs:
registry-url: "https://registry.npmjs.org"
- name: Publish to npm
# 只有官方仓库发 npm;fork(dev 预览)没有 NPM_TOKEN,跳过以免红叉
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public
env:
@@ -0,0 +1,49 @@
name: Sync release to Gitee
# Manually mirror a published GitHub release's assets to the matching Gitee
# release. Use this to repair a release whose Gitee mirror is incomplete (e.g.
# the Release job timed out mid-upload). It runs ONLY the idempotent Gitee sync
# step — it does not run GoReleaser and does not touch the GitHub release, so
# there is no release outage. The sync script skips assets already on Gitee, so
# this only uploads what is missing.
on:
workflow_dispatch:
inputs:
version:
description: "Release tag to mirror to Gitee (e.g. v1.0.42)"
required: true
type: string
permissions:
contents: read
jobs:
sync-gitee:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Download GitHub release assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
mkdir -p dist
gh release download "${{ inputs.version }}" \
--repo "${{ github.repository }}" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
ls -la dist
- name: Mirror release to Gitee (China)
# Idempotent: uploads only assets not already present on the Gitee release.
run: ./scripts/release/sync-to-gitee.sh
env:
VERSION: ${{ inputs.version }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
+12
View File
@@ -16,6 +16,7 @@ dws
test/cli/testdata/
tmp/
test/cli_compat/testdata/
/internal/compat/testdata/*
.gitignore
.worktrees/
@@ -25,8 +26,19 @@ test/cli_compat/testdata/
*.pem
*.key
credentials*
!skills/**/credentials.md
plans
_docs
dws.zip
*.code-workspace
/dingtalk-workspace.zip
# envelope/discovery.pre.json synced via Portal, not git-tracked
/envelope/discovery.pre.json
# local/pre-release MCP service configs may contain personal gateway keys
/docs/mcp/serviceconfig-pre*
# 功能测试运行产物
results.jsonl
test/dev_functional/results.jsonl
+2 -1
View File
@@ -64,7 +64,8 @@ changelog:
release:
github:
owner: DingTalk-Real-AI
# 用当前运行 CI 的仓库 owner: fork CI 发到 fork, 官方 CI 发到官方, 两边都对
owner: "{{ .Env.GITHUB_REPOSITORY_OWNER }}"
name: dingtalk-workspace-cli
draft: false
prerelease: auto
+584
View File
@@ -4,6 +4,590 @@ 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/).
## [Unreleased]
## [1.0.46] - 2026-07-01
### Fixed
- **PAT agentCode grants no longer split from follow-up command checks** (`internal/auth/agent_code_detect.go`, `internal/app/runner.go`, `internal/pat/chmod_test.go`) — explicit `DINGTALK_DWS_AGENTCODE` declarations are now forwarded verbatim as the common cross-host contract, and unknown hosts no longer synthesize `custom` into `x-dingtalk-dws-agent-code` / `x-dws-agent-instance-id`. `pat chmod --agentCode` remains the highest-priority grant target and still wins over the env fallback.
## [1.0.45] - 2026-06-29
This release adds **multi-organization (profile) support** (#500): `dws` can stay logged in to several DingTalk organizations at once and switch between them, while staying fully backward/forward compatible with the previous single-org token. A profile is one logged-in organization (corp); the current profile decides which org a command runs against. The release also hardens the new credential store for concurrency and corruption recovery, documents the capability in both the mono and multi skill sets, and flips `--ai-tag` on by default so messages sent through `dws` carry the DingTalk 「通过AI发送」 badge (#524).
### Added
- **Multi-organization login & `profile` management** (`internal/auth/profiles.go`, `internal/app/profile_command.go`) — `dws auth login` against a new organization adds a profile (the first login becomes the primary); `dws profile list` shows logged-in orgs with primary / current markers, status and validity; `dws profile switch <name|corpId|->` persistently switches the default org (`-` toggles back to the previous one, no-arg opens a TUI selector on a terminal); `dws profile use` is an alias of `switch`. `dws auth status [--profile <name>]` reports a specific profile. Credentials are stored per organization in keychain slots keyed by corpId (`auth-token:<corpId>`), with a plaintext `profiles.json` registry holding only metadata and the primary/current/previous pointers (no tokens).
- **Global `--profile <name|corpId>` flag** — run a single command against a specific organization without changing the default (one-shot; does not move currentProfile). Cross-org reads are orchestrated by the agent (list profiles → query each with `--profile` → merge); there is intentionally no built-in `--all-orgs`.
- **Backward / forward compatibility with the legacy single token slot** — a pre-existing single-slot token is migrated into `auth-token:<corpId>` and marked primary on first multi-profile use; the current (or primary) profile's token is mirrored back into the legacy slot so older binaries and the embedded host keep working. `profiles.json` is additive and ignored by older versions.
- **`dingtalk-profile` and `dws-shared` skills + multi-org documentation** (`skills/`) — a standalone `dingtalk-profile` skill plus a new `dws-shared` skill that carries auth, global flags and the multi-org rule, so every multi-mode product skill's PREREQUISITE resolves and all read/search skills inherit cross-org behavior. The mono skill gains a "multi-org / profile" section, trigger conditions, a decision-tree entry and a corrected logout danger note. Multi-mode install now always ships `dws-shared` even when `--skill` / `--exclude` narrows the set.
### Changed
- **`--ai-tag` now defaults on — DingTalk 「通过AI发送」 badge for dws-sent messages** (`internal/helpers/chat.go`, #524) — `chat message send` / `reply` flip the `--ai-tag` default from false to true, attaching the AI `clawType` by default so messages sent through `dws` (and by AI agents) transparently carry the 「通过AI发送」 badge; pass `--ai-tag=false` to send as the user with no badge.
- **Concurrency-safe, self-healing `profiles.json`** (`internal/auth/profiles.go`, `internal/auth/token.go`) — every read-modify-write on `profiles.json` and the legacy mirror is serialized under the existing dual-layer (process + cross-process) lock, split into public (locking) entry points and lock-free `*Locked` variants so the non-reentrant lock is never re-acquired (the refresh path and the load-path migration use the lock-free savers). `profiles.json` and the token marker are written via per-write random temp names + atomic rename so concurrent writers can no longer corrupt a fixed `.tmp`. An unparseable `profiles.json` is quarantined (`*.corrupt-*`) and rebuilt empty so the CLI self-heals; `auth reset` / `logout` proceed even when it cannot be read and sweep the quarantined files.
### Fixed
- **No silent fallback to a different org's token** (`internal/auth/token.go`) — when the resolved current/primary profile's keychain slot fails to read and no `--profile` was given, the loader now only falls back to the legacy single slot if it belongs to the same organization; otherwise it surfaces the error instead of acting as a different org.
- **Legacy mirror no longer wiped on a transient keychain read error** (`internal/auth/profiles.go`) — `SyncLegacyTokenMirror` distinguishes "token genuinely absent" from "keychain momentarily unreadable" and keeps the existing mirror in the latter case, so a host app's login state is not dropped by a transient failure.
## [1.0.44] - 2026-06-28
This release hardens the dynamic-command surface and finishes the dws-wukong parity pass for structured input. Phantom override commands whose backing MCP tool isn't deployed are hidden from `--help`; `report entry submit` reads `--contents-file` / stdin natively; structured JSON flags accept `@file` / `@-`; and `sheet range update` / `range read` now accept the same plain shapes wukong does (scalar cells, flat `values`, null-clears-cell, a `--hyperlinks` flag). On the wukong01 sandbox this lifts the full open-edition cli_to_mcp pass rate from 77.6% to 95.5% (sheet 28.5% → 99.8%, report → 100%); the remaining failures are account / org / out-of-scope, not CLI defects.
### Added
- **`dingtalk-dev` skill: image-upload → `mediaId` recipe + per-resource command discovery** (`skills/multi/dingtalk-dev/references/`) — documents how to obtain a `mediaId` for app / robot icons via the DingTalk OpenAPI (`credentials get` → `gettoken` → `/media/upload?type=image` → `--icon-media-id` → read back), since the dev command set has no upload command; and adds a "discovering commands" block to all 10 product refs pointing at each group's `--help` and `dws schema dev.app.<group>.<method>` (`dws schema dev.connect` for connect), so agents inspect commands instead of relying on memory.
- **`report entry submit --contents-file <path>` / `--contents -` (stdin) read natively** (#514, `internal/compat/report_hooks.go`) — the envelope publishes `entry submit` (MCP `create_report`) with a `--contents` (json_parse, required) flag plus a sibling `--contents-file` that had no transform / mapsTo, so a `--contents-file`-only submit silently sent `contents: [null]` and the report failed (only inline `--contents` worked, which is why `report create` succeeded while `report entry submit --contents-file` did not). A build-time compat hook now resolves the file / stdin natively (10MB cap, UTF-8 check, wukong priority `--contents-file` > `--contents -` > inline) and relaxes the individual `required` on `--contents` into a `contents` / `contents-file` one-of group. No discovery-config change needed.
- **`@file` / `@-` input for structured JSON flags** (`internal/compat/transform.go`) — `json_parse` / `json_parse_strict` now expand a leading `@` before parsing (`@-` reads stdin, `@<path>` reads a file), so long / complex payloads (many records, big 2D cell ranges, filter criteria) skip shell-quoting hell. A JSON / YAML value never starts with `@`, so the sentinel is unambiguous; the error hint that already advertised `@path/to/file.json` is now truthful. `sheet`'s shared `sheetParseJSONFlag` routes through `cli.ResolveInputSource` so the same support reaches `--values` / `--criteria` / `--sort-keys`.
- **`sheet range update --hyperlinks`** (`internal/helpers/sheet.go`) — a wukong-shaped 2D hyperlink grid (`[[{"type":"path","link":"...","text":"..."}]]`) overlaid onto the cells grid as each cell's `hyperlink` field; `--values` or `--hyperlinks` is now required (at least one).
### Changed
- **Phantom override commands hidden from `--help`** (#515, `internal/compat/dynamic_commands.go`) — override leaves whose backing MCP tool isn't actually deployed used to render in `dws <svc> --help` and then fail at invocation with *tool not found*. A tool-existence guard now hides them, and command groups left empty by the hidden leaves are collapsed, so `--help` reflects only invokable commands. Skill references are re-aligned to the real CLI surface (phantom commands dropped; role/duty "who is responsible" queries routed to `aisearch`, not `contact`).
- **`sheet range update` accepts scalar cells; `sheet range read` projects a flat `values`; `--values '[[null]]'` clears a cell** (`internal/helpers/sheet.go`, `internal/helpers/sheet_cell_validation.go`) — dws-wukong parity. `range update` (set_cell_range) auto-wraps a scalar cell (string / number / bool) into `{type:text,text:"..."}` instead of rejecting it, so the plain `[["姓名","部门"]]` shape that `sheet append` and wukong's update_range accept now works; a null cell clears content (matching wukong); `{}` still means keep-original. `range read` (get_cell_infos) now also exposes a flat `values` 2D array next to the rich `cells` payload, matching wukong's get_range shape without dropping cell styles.
- **report skill aligned to `entry submit` / `inbox list` / `outbox list`** (`skills/multi/dingtalk-report/`, `skills/mono/references/intent-guide.md`) — the multi skill tree was two versions behind and still taught the deprecated flat aliases (`report create` / `sent` / `list` / `detail` / `stats`) and falsely claimed `report inbox` was unimplemented. Re-aligned to the canonical resource.verb commands consistently (old aliases still execute with a stderr deprecation notice).
## [1.0.43] - 2026-06-26
This release aligns the open edition's CLI surface with **dws-wukong** across the communication domain (chat / mail / minutes / todo / calendar / contact / aisearch / live / report / ding) and the structured-office domain (aitable / sheet / drive / wiki / doc), and switches the discovery version code from `bamboo` to `cedar` so the aligned command tree is served from its own discovery config.
### Added
- **`calendar book get|search` and `calendar acl list`** (cedar discovery overrides) — query a specific calendar (primary via `--id primary`), fuzzy-search calendars by name, and list a calendar's access-control entries. Maps to the calendar MCP `get_calendar` / `search_calendar` / `list_acls` tools.
- **`calendar attendee list|add|delete`** (`internal/helpers/calendar_commands.go`) — manage event participants under the wukong-aligned `attendee` naming (equivalent to the legacy `participant` group; calls `get/add/remove_calendar_participant`).
- **`minutes tag list` and `minutes tag query --tag-id`** — list a user's AI-minutes tags and query minutes by tag (`query_user_tag_list` / `query_minutes_by_tag_id`).
- **`minutes list mine|shared|all`** (`internal/helpers/minutes_commands.go`) — list own / shared / all minutes with renamed output fields.
- **`mail folder create|update|delete`, `mail template create|list|get|update|delete`, `mail contact create|list|update|batch-delete`, and `mail message list`** — full mail folder / message-template / contact CRUD plus folder-scoped message listing.
- **`chat file upload`** (`internal/helpers/chat_file.go`) — upload a local file (init/PUT/commit) or a remote URL to a conversation's file space.
- **`todo task add-attachment`** (`internal/helpers/todo_commands.go`) — attach a local file to a todo (multi-step upload).
- **aitable extensions** (`internal/helpers/aitable_extra.go`) — advanced permission / roles, view sub-commands (lock / duplicate / frozen-cols / row-height / fill-color-rule / card / timebar), section node management, workflow enable/disable, record `upsert` / `share-url` / `history-list` / primary-doc, and field search-options. Helper tools route to the hardcoded `aitable-helper` supplement endpoint.
- **sheet, drive, wiki, doc helper coverage** synced from dws-wukong (`internal/helpers/sheet.go`, `drive.go`, `wiki.go`, `doc.go`).
### Changed
- **Discovery version code `bamboo` → `cedar`** (`internal/market/registry.go`; `discoveryAPIPath = "/cli/discovery/apis/cedar"`) — version codes step by first letter (bamboo → cedar → …); `cedar` carries the dws-wukong alignment. Older binaries keep reading `bamboo`, so the change is isolated to this release line. All test/mock/generator fixtures updated to the cedar path.
- **CLI output envelope aligned with wukong for cross-edition parity** (`internal/app/runner.go`, `internal/compat/registry.go`) — dry-run prints a `DRY-RUN Arguments:` line, successful results carry `success: true`, missing-required-flag wording is unified to `missing required flag(s): --x`, and OutputTransform applies to the response content layer.
- **New flag transforms** (`internal/compat/transform.go`) — `parse_bool` (explicit boolean strings so `--flag false` is honoured) and `attendance_class_check_time` (`HH:mm` → UTC+8 milliseconds for shift check-times).
- **`--calendar-id` accepted on calendar event / participant / room / attachment commands** so calendars other than the primary can be targeted.
### Fixed
- **Client-side validation** for calendar recurrence completeness and attendance schedule / class / group inputs, surfacing input errors before they reach the server.
## [1.0.42] - 2026-06-25
This release rounds out `dws dev connect` — bridge a DingTalk robot to your local AI (Claude Code / Codex / opencode / Qoder / …): a generic `custom` channel for any headless CLI tool, in-chat `/new` / `/clear` session commands aligned to each agent's real session op, and a fix for long opencode turns being cut at 30 seconds.
### Added
- **`dws devapp robot connect` — generic `custom` channel for self-built / unsupported AI tools** (issue #37; `internal/helpers/devapp_connect.go`, `internal/helpers/connect_stream.go`) — a new `--agent-cmd "<command>"` flag (and `custom` channel) lets the bot forward to any headless AI CLI that takes a question as its trailing argument and prints the answer to stdout, so tools that aren't built-in (e.g. 网易有道龙虾 LobsterAI) or self-built agents can be onboarded without code changes. `--agent-cmd` forces the `custom` channel unless `--channel` is set explicitly; detection also falls back to `custom` when `DWS_AGENT_CMD` is present.
### Changed
- **`robot connect` now hints how to match terminal answer quality** (issue #39; `internal/helpers/devapp_connect.go`) — when neither a work dir nor a knowledge source is configured, the connector prints a one-time note that the bot runs in a clean temp dir without local project context, pointing at `--agent-workdir` / `--knowledge-dir` / `--knowledge-source` / `--agent-model`. The robot quickstart gains matching FAQ entries, plus a clarification that step 3 (`robot connect`) produces no approval ticket (issue #19).
- **`robot connect` session commands `/new` vs `/clear` now use each channel's real session op** (PR #20; `internal/helpers/connect_opencode.go`, `internal/helpers/connect_stream.go`) — `/new` (and `/start`, `/reset`) opens a fresh session and leaves the previous one intact (resumable where the agent supports it); `/clear` actively disposes the current session through the agent's real delete primitive — opencode issues `DELETE /session/:id`. Channels whose agent exposes no delete in the mode DWS drives it (Codex app-server, Qoder stream, Claude-family exec) fall back to a reset, so `/clear` behaves like `/new` there. Previously both commands only dropped the local `conversationId → sessionId` mapping, so the two were indistinguishable and opencode sessions were never disposed (they leaked).
### Fixed
- **`robot connect` no longer aborts long opencode turns at 30 seconds** (PR #19; `internal/helpers/connect_opencode.go`) — the shared opencode HTTP client hard-coded a 30s `Timeout` that covered every request, including `POST /session/{id}/message`, so a long agent turn (e.g. a multi-minute research report) was killed mid-flight with `context deadline exceeded (Client.Timeout exceeded while awaiting headers)` even though the per-turn budget (`DWS_AGENT_TIMEOUT_MS`, default 300s) was far larger. The client-level deadline is removed so the per-request ctx governs the round-trip; only the `/global/health` probe keeps a short 10s timeout so startup detection stays snappy.
## [1.0.41] - 2026-06-24
This release makes the installers work from mainland China out of the box (no env var) and keeps the Gitee mirror in sync automatically.
### Added
- **Auto-fallback to the Gitee mirror when GitHub is unreachable** (#492; `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh`) — the installers probe GitHub Releases on startup and, when it is unreachable (typical in mainland China), automatically resolve the version and download every asset (binary, `checksums.txt`, `dws-skills.zip`) from the Gitee mirror instead. A plain `curl … | sh` now works in China with no `DWS_GITEE_REPO` needed. Explicit `DWS_GITEE_REPO` still wins, `DWS_NO_FALLBACK=1` forces GitHub, and local source-checkout installs skip the probe.
### Changed
- **CI mirrors repo code to Gitee automatically** (#493; `.github/workflows/mirror-to-gitee.yml`) — the mirror workflow now pushes `main` + tags to the Gitee mirror over HTTPS using `GITEE_TOKEN` (no SSH key), on every push to `main` and every tag, keeping the Gitee `raw/main` install scripts and tags in sync without any manual `git push`. Gated on `GITEE_TOKEN`; skips cleanly when unset.
## [1.0.40] - 2026-06-24
This release adds China-accessible install mirrors so the CLI installs reliably from mainland China, where GitHub raw + Releases are slow or fail.
### Added
- **China mirror via Gitee + npmmirror** (#486; `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh`, `scripts/release/sync-to-gitee.sh`, `.github/workflows/release.yml`, `.github/workflows/mirror-to-gitee.yml`) — an opt-in `DWS_GITEE_REPO` env var makes all three installers resolve the latest version and every release asset (binary, `checksums.txt`, `dws-skills.zip`) from the Gitee OpenAPI v5 instead of GitHub; with it unset, installation defaults to GitHub (fully backward compatible). The release pipeline mirrors release attachments to the matching Gitee release after each tag (gated on `GITEE_TOKEN`/`GITEE_REPO`), and a hub-mirror workflow keeps the repo code in sync (gated on `GITEE_PRIVATE_KEY`). README documents three China install channels: Gitee raw script, Gitee release binaries, and the npm package via `registry.npmmirror.com`.
- **Skills embedded in the binary** (#488; `skills_embed.go`, `internal/app/skill_setup.go`, `internal/app/skill_setup_embed.go`) — the `skills/` tree (mono + multi) is embedded into the `dws` binary via `go:embed` and `dws skill setup` defaults to the embedded copy, refreshing the installed skill instead of silently reusing a stale copy probed from the current working directory — so skills install offline with no separate download.
## [1.0.39] - 2026-06-18
This release makes the AI-sent indicator opt-in. 1.0.38 unconditionally tagged every user-identity send/reply with the edition claw identity, so the IM server rendered a "Send from AI" badge under every message — and on the open edition a stale hardcoded value even leaked the Wukong-branded label (「悟空AI发送」) to external users. The badge is now off by default and shown only when the caller explicitly asks for it.
### Added
- **`--ai-tag` opt-in flag for `chat message send` / `chat message reply`** (#477; `internal/helpers/chat.go`) — by default no `clawType` tool argument is attached, so delivered messages carry no "Send from AI" badge. Passing `--ai-tag` attaches `edition.ClawType()` so the IM server renders the badge (open edition `openClaw` → 「通过AI发送」; the wukong overlay sets its own value → 「悟空AI发送」). Covers the text/Markdown, rich-media, and `--user`/`--open-dingtalk-id` direct send paths plus `reply`. Bot (`send-by-bot`) and webhook sends are intentionally untouched — they already render as bot messages. The badge is opt-in so dws does not brand every message a user sends.
### Fixed
- **`dws chat message reply` no longer leaks the Wukong AI label on the open edition** (#475, fixes #474; `internal/helpers/chat.go`, `pkg/edition/edition.go`) — the reply path hardcoded `clawType: "wukong"`, so open-source quoted replies were tagged 「悟空AI发送」 by the IM server, leaking Wukong branding to external users (reported by an external customer integrating via openclaw). The value now derives from the edition via the new `edition.ClawType()` accessor (open → `DefaultOSSClawType` = `openClaw`), and — together with #477 — is only attached when `--ai-tag` is passed. The earlier fix existed on a branch (PR #450) but was never merged to main; #475 cherry-picked it.
## [1.0.38] - 2026-06-16
This release adds client-side agent attribution for usage stats, fixes two commands that silently misbehaved (`dws sheet export` hanging, `dws upgrade --dry-run` actually upgrading), hardens the document write path against server-rejected characters, and makes the long-broken `--no-browser` login flag actually work.
### Added
- **Client-side `agent_code` detection + per-channel agent instance id for usage stats** (#467; `internal/auth/agent_code_detect.go`, `internal/auth/identity.go`, `docs/agent-code.md`) — every MCP request now carries `x-dingtalk-dws-agent-code` (which agent host is driving dws — e.g. `claudecode` / `codex` / `qoder` / `cursor` / `hermes` / `openclaw`, falling back to `custom`), `x-dws-agent-instance-id` (a per-machine×channel id, `dwsa_<base62(sha256(machineId|agent_code))>`), the existing machine-level `x-dws-agent-id`, and `X-Cli-Version`. Detection is a confidence ladder, each signature verified on real hosts / official docs (never guessed; anything unrecognized resolves to `custom`): T0 explicit `DINGTALK_DWS_AGENTCODE`, T1 per-agent env signatures, T2 `VSCODE_BRAND` covering the whole VS Code fork family, T3 the macOS `__CFBundleIdentifier` map, T4 `custom`. `identity.json` migrates v1 → v2 transparently and keeps `x-dws-agent-id` machine-level for continuity. **Trust boundary:** `agent_code` and both ids are client self-reported and forgeable — they are for stats / observability only and must not be used for auth, authorization, rate-limiting, billing, or revocation. Server-side gateway work (header passthrough allowlist + logging the fields into the warehouse) is required before the data lands and is tracked separately.
### Fixed
- **`dws sheet export` no longer hangs for the full ~5-minute poll timeout** (#462; `internal/compat/pipeline.go`) — the pipeline poll loop compared the API status against `pollUntilValue` with case-sensitive `==`, but the API returns `"success"` while the pipeline config declares `"SUCCESS"`, so the match never fired and the loop spun until timeout. Switched to `strings.EqualFold`, aligning with the case-insensitive `normalizeAsyncStatus` helper already used for `doc export` / `aitable export`.
- **`dws upgrade --dry-run` now previews instead of performing a real upgrade** (#416, fixes #364; `internal/app/upgrade.go`) — `newUpgradeCommand` registered no `--dry-run` flag and never read the global persistent one, so `--dry-run` fell through and ran a real, irreversible upgrade (download + binary replace), directly contradicting the flag's documented `预览操作内容,不实际执行` contract. It now resolves the target release and platform asset (so "already latest" / "no build for this platform" is still surfaced), prints the 1–5 steps it *would* perform via the side-effect-free `writeDryRunPlan`, and returns before any backup / download / replace. Covered by `TestWriteDryRunPlan_*` and an updated help test.
- **`dws doc create` / `dws doc update` strip server-rejected characters instead of failing** (#465; `internal/helpers/doc.go`, `internal/helpers/doc_jsonml.go`) — the Markdown write path sent raw content straight through, and the dangerous-Unicode strip only ran on the JSONML branch, so content carrying C0 control characters (anything `< 0x20` except `\t` / `\n`), DEL (`0x7F`), or zero-width / line-separator codepoints (`U+200D`, `U+2028`, `U+2029`) — common in LLM-generated or copy-pasted text — was rejected by the server-side `RejectControlChars` validator and the command failed. `stripDocDangerousUnicode` is renamed to `stripDocInputUnsafe`, extended to match the authoritative `apiclient.rejectDangerousChars` set, and applied on both the Markdown and JSONML node write paths. Tab and newline are preserved. Ported from dws-wukong.
- **`dws auth login --no-browser` is now honored** (#365; `internal/app/auth_command.go`, `internal/auth/device_flow.go`, `internal/auth/oauth_provider.go`) — the flag was already defined (and hidden) but never wired to the login providers, so the browser always opened regardless. The value is now passed into `DeviceFlowProvider.NoBrowser` / `OAuthProvider.NoBrowser` and gates the `openBrowser` call; the flag is also unhidden so headless / remote sessions can discover it.
## [1.0.37] - 2026-06-11
This release realigns the npm channel and hardens PAT batch grants. Background on the npm realignment: 1.0.36 was re-cut on GitHub on 2026-06-11 to fold in the canonical-tree poisoned-cache guard (#454), but the npm registry permanently forbids republishing a version number, so the npm package stayed on the original, unguarded cut. 1.0.37 is therefore the first version where **every** distribution channel — GitHub releases, `dws upgrade`, the install scripts, and npm — ships the same guarded build. If you installed 1.0.36 from npm, upgrade to this version.
### Fixed
- **PAT batch grants carry the agent identity and require explicit confirmation** (#455; `internal/pat/chmod.go`, `internal/auth/channel.go`, `internal/app/runner.go`) — an explicit `--agentCode` flag or the `DINGTALK_DWS_AGENTCODE` env var is now carried into PAT batch plan/grant arguments instead of being dropped, and a missing agentCode is forwarded as absent so the PAT core can apply the server-side default rather than failing. Batch grants now refuse to execute without an explicit `--yes` (dry-run and single-scope grants keep their existing behavior), closing the gap where a multi-scope grant could fire without a deliberate confirmation. Only the canonical env name `DINGTALK_DWS_AGENTCODE` is recognized; draft/reversed spellings from earlier iterations are ignored. Verified against prepub: dry-run, single grant, flag-priority grant, and batch grant all resolve the target agentCode, with the granted rows confirmed server-side. Tests: `internal/pat/chmod_test.go`, `internal/pat/browser_policy_test.go`, `test/unit/pat_host_owned_signal_test.go`.
## [1.0.36] - 2026-06-10
This release closes out the poisoned-discovery-cache lock-out for good, with four layers of defense landing together. The lock-out class (seen again on 2026-06-09 as `chat_permission_grant flag redefined: params`): the dynamic command tree is built from cached discovery data **before** Cobra dispatches any command, so a pflag panic fed by a poisoned cache aborted *every* invocation — including `dws cache refresh` and `dws upgrade`, the very commands that could repair it. Now: (1) any panic during the build is recovered instead of crashing (#447), (2) the four known envelope shapes that made pflag panic are skipped at registration so they never fire (#449), (3) when an unknown panic class does fire, the CLI quarantines the poisoned cache and rebuilds itself from a fresh fetch — and `dws upgrade` clears the discovery caches after every binary swap, so simply getting this version onto a machine is enough to escape, no manual cache surgery (#452), and (4) the same guards now also cover the canonical `dws mcp` tree, which is built even earlier and sat outside all three defenses as originally cut (#454 — this release was re-cut on 2026-06-11 to include it; verified against the preserved real poisoned cache from the 2026-05-25 incident). Also in this release: `dws devdoc` gains RAG-backed Open Platform doc search and a new error-diagnosis command (#434), and `dws doc create` stops producing documents with two identical titles (#448).
**Escaping a locked-out older binary**: a binary ≤1.0.35 bricked by a poisoned cache cannot run `dws upgrade`. Either bypass the cache for one invocation with `DWS_CACHE_DIR=$(mktemp -d) dws upgrade`, or delete `~/.dws/cache/<partition>/tools/` by hand, or reinstall via the install script. Once 1.0.36 is on the machine this never needs doing again.
### Added
- **`dws devdoc` — RAG-backed Open Platform doc search and error diagnosis** (#434; `internal/helpers/devdoc.go`, `internal/transport/client.go`) — `dws devdoc article search` now routes to the upstream `search_open_platform_docs_rag` tool, returning structured RAG/reference payloads (the CLI stays a thin invoker; no extra AI analysis layer). New `dws devdoc error diagnose` (alias `troubleshoot`) routes to `search_open_error_code_rag` for diagnosing DingTalk Open Platform API errors, with `--request-id` (hidden `--trace-id` kept for compatibility), `--error-code`, `--error-message`, `--api`, `--context`, `--query`, `--page`, `--size`. Transport-side: query parameters required by DingTalk MCP gateway URLs are preserved on the wire but their values are redacted from debug logs. Default MCP / skill hosts stay on production `https://mcp.dingtalk.com` (prepub remains runtime-configurable). Skill docs (mono + multi `dingtalk-devdoc`) and `docs/command-index.md` updated alongside.
### Fixed
- **CLI no longer bricks when the dynamic command build panics — degrades to built-in commands** (#447; `internal/app/legacy.go`) — `buildEnvelopeCommandsSafe` wraps the envelope-driven build in a local `recover()`. On panic the CLI logs it, prints a stderr hint, and falls back to the hardcoded helper commands, so `auth` / `cache` / `doctor` / `version` / `upgrade` and the helpers stay alive and `dws cache refresh` can rebuild the poisoned cache. Before this, the only recovery from the pre-1.0.32 lock-out class was manually deleting cache files; the duplicate-flag class itself had been fixed at the builder level, but any *future* panic class in the cache-driven build would have bricked the CLI again. Tests: `TestNewLegacyPublicCommandsPanicFallsBackToHelpers`, `TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath`.
- **Envelope-driven flag registration no longer panics on the four known malformed-envelope shapes** (#449; `internal/compat/registry.go`) — while reproducing the lock-out byte-for-byte, four envelope shapes were found still forwarded to pflag calls that panic, each bricking every invocation: a flag named `params` / `json` colliding with the reserved payload flags (the original `flag redefined: params` — earlier dedup fixes covered the alias list and Detail-schema path but not the primary name); two bindings resolving to the same long flag name across bindings; two flags claiming the same shorthand; and a multi-character shorthand. Two small guards applied at every registration site (`ApplyBindings`, `registerPositionalAliasFlags`): `canRegisterFlag` skips duplicate/reserved long names (the value stays reachable via `--params`), and `safeShorthand` drops an invalid or already-taken shorthand while keeping the long flag. The trailing `--json` / `--params` registration is now idempotent. Defense in depth with #447: the escape hatch should never trigger for these known vectors. Test: `TestBuildDynamicCommandsSurvivesMalformedFlagEnvelope` (5 table-driven vectors).
- **Poisoned discovery cache now self-heals: quarantine + rebuild on panic, and `dws upgrade` clears discovery caches** (#452; `internal/app/legacy.go`, `internal/app/upgrade.go`, `internal/cache/store.go`) — #447's recovery is upgraded from "degrade and ask the user to run `dws cache refresh`" to a two-stage self-heal: on the first build panic the partition's discovery cache is moved aside to `<partition>.quarantined` (kept on disk for inspection; a previous quarantine is replaced so nothing accumulates — new `Store.QuarantinePartition`) and the build retried once against a fresh fetch. If the retry succeeds the user gets the full dynamic command tree with zero manual steps; only a second panic (remote envelope itself still poisoned, or offline) degrades to helper commands with the `cache refresh` hint. Additionally `dws upgrade` purges discovery-derived caches (`market` / `tools` / `detail` across all partitions — new `Store.PurgeDiscoveryData`) after a successful binary swap, leaving the co-located `downloads/` cache untouched, so an upgraded binary always rebuilds its command tree from fresh data instead of inheriting snapshots written by the old version. Tests: `internal/cache/store_quarantine_test.go`, rewritten `internal/app/legacy_panic_fallback_test.go` (self-heal success, double-panic degradation, no-cache no-op, happy path).
- **Canonical `dws mcp` tree no longer escapes the poisoned-cache guards** (#454; `internal/cli/canonical.go`, `internal/app/root.go`) — the canonical tree is assembled from cached catalog data *before* the legacy command build, so a pflag panic there — a tool schema property named after the reserved `--params` flag, exactly what the 2026-05-25 incident cache contained — bypassed #447/#449/#452 entirely and still bricked every invocation, including on this release as originally cut. Two layers, mirroring the existing guards: `applyFlagSpecs` skips reserved (`--json`/`--params`), duplicate, and alias-colliding flag names and sanitizes shorthands (`canRegisterToolFlag` / `safeToolShorthand`; a skipped property stays reachable through the reserved JSON payload flags), and `newMCPCommand` wraps the build in the #452 recover → quarantine → retry-once → degrade-to-stub sequence. Verified against the preserved real poisoned cache: the original cut locks out on `--version` / `cache refresh` / `doctor`; this build self-heals on first run and `cache refresh` clears the poison. Tests: `internal/cli/canonical_flag_guard_test.go` (4 cases), `internal/app/canonical_panic_fallback_test.go` (4 cases mirroring the legacy fallback suite).
- **`dws doc create` no longer produces a document with two identical headings** (#448; `internal/helpers/doc.go`) — the platform renders the document name as the page title, and LLM agents habitually repeat `# <title>` as the markdown body's first line despite the skill docs saying not to, so duplicate-heading documents kept appearing. The `doc create` helper (which wins the envelope merge via `preferLegacyLeaf`) now strips a leading ATX H1 whose text exactly equals `--name` (trimmed, case-insensitive) before forwarding to `create_document`, printing a stderr note so agents learn the convention. Deliberately conservative: only an exact match is removed (`# 背景` stays), ATX closing hashes are handled without over-trimming names ending in `#` (e.g. `C#`), H2+/setext headings are never touched, and a body that is nothing but the duplicate H1 omits the `markdown` param instead of sending an empty string. JSONML bodies are out of scope. Tests: `TestStripLeadingDuplicateTitleHeading` (9 cases) plus three end-to-end cobra tests asserting the exact `markdown` param sent.
## [1.0.35] - 2026-06-08
### Fixed
- **`chat message send` @-mentions not rendered in group / direct chat** (#433, `internal/helpers/chat.go`) — when sending a group message or an openDingTalkId direct message (`send_personal_message`) as the current user, the `content` body was packed with `json.Marshal`, whose default HTML escaping turns the `<` `>` in `<@openDingTalkId>` / `<@all>` into `<` `>`. The DingTalk client renders @-mentions by matching the **literal** `<@...>` token, so after escaping the match fails and the mention shows as plain text — while the API still returns `success`, masking the bug. Fix: add `marshalMessageContent`, which serializes `{title,text}` with `json.Encoder` + `SetEscapeHTML(false)`; both the group and openDingTalkId-direct `send_personal_message` paths now use it, preserving the literal `<@...>`. Added regression test `TestChatMessageSendContentNotHTMLEscaped` asserting the content keeps the literal token and is never HTML-escaped. Verified on a real device: `@someone` and `@all` both render as clickable blue mentions.
- **`chat` skill docs & scripts aligned to direct-chat `list-direct`** (#424) — `chat message list` now supports group chats only (`--user` / `--open-dingtalk-id` removed); reading a direct chat moves to the dedicated `list-direct` command, but the skill docs and scripts still taught `chat message list --user`, which now errors with `unknown flag: --user`, also breaking `chat_history_with_user.py` (listed as the "preferred" way to query direct chats). This update: `skills/{mono,multi/dingtalk-chat}/references/products/chat.md` switches `message list` to group-only and documents the new `list-direct` command, syncing the intent routing / key-distinction / context-passing tables / caveats; `skills/mono/references/best_practices/01-messaging.md` changes query-private-chat from `list --user` to `list-direct` (the multi version was already updated); `chat_history_with_user.py` (mono + multi) now calls `list-direct` and fixes response parsing (unwraps `result.messages`, aligns `createTime/content/sender` fields — it previously crashed on `'str' object has no attribute 'get'`). Direct-chat sending still uses `chat message send --user` (since v1.0.34 the direct-send rpc is folded into the `send` command; there is no separate `send-direct`). Docs/scripts only; no change to CLI binary behavior.
- **`pat chmod` batch authorization did not pass through `agentCode`** (#414, `internal/pat/chmod.go`) — the batch plan / grant paths (`buildBatchPlanArgs` / `batchArgs`) previously carried `agentCode` only in the single-grant `toolArgs`; batch calls omitted it, so a batch authorization with an explicit `agentCode` was processed under the default agent. Fix: the batch plan / grant args now also carry `agentCode`, matching the single-grant path.
- **`pat` JSON output escaped the authorization URL into an unreadable form** (#401, `internal/pat`) — the authorization URL attached to PAT error messages, after default HTML escaping, turned `&` into `&`, breaking the link when copied / recognized on mobile. Fix: the PAT error-enrichment JSON output now uses `SetEscapeHTML(false)` (scoped to PAT JSON only), preserving the readable `&` separators.
## [1.0.34] - 2026-06-03
### Changed
- **Service discovery path now carries a version-coded segment** (`internal/market/registry.go`) — the server-list endpoint moves from `/cli/discovery/apis` to `/cli/discovery/apis/bamboo`. The path is now a single `discoveryAPIPath` constant so future version bumps touch one place. Only the path changes; the MCP base host stays on production `https://mcp.dingtalk.com` and the auth / skill / doctor endpoints are untouched. Discovery via the edition `DiscoveryURL` hook (full-URL `FetchServersFromURL`) is unaffected. Server side must serve the new path.
### Removed
- **`dws aiapp` — AI application product taken offline** — removed the `aiapp` product surface (`create` / `query` / `modify`) from the CLI: deleted `internal/helpers/aiapp.go`, dropped it from the generator coverage targets and `knownRegistryProducts`, removed the `aiapp` skill references (mono `references/products/aiapp.md` + `dingtalk-aiapp` multi skill), and unpublished the `aiapp` server from the service-discovery envelope. Product count drops from 19 to 18.
## [1.0.33] - 2026-06-02
This release merges the multi-contributor `pre-mcp-discovery` feature branch into `main` as a single squash (#391), bringing a large batch of new product surface — full DingTalk **docs** (`doc`), **knowledge base** (`wiki`), **AI app** (`aiapp`), AI-table **forms** + **import/export**, and reworked **mail** / **todo** / **report** command trees — while keeping service discovery pinned to production `https://mcp.dingtalk.com` (the branch's `pre-mcp.dingtalk.com` endpoint change was deliberately excluded; the four host constants in `skill_command.go` / `auth/endpoints.go` / `cli/loader.go` / `market/registry.go` stay on prod). It also folds in the portable auth bundle (`dws auth export` / `import`, #357) and PAT batch authorization (#389).
### Added
- **`dws doc` — full DingTalk document command family** (#387, #362, #388, #390; `internal/helpers/doc.go`, `internal/helpers/doc_jsonml.go`, `internal/helpers/docjsonml/`) — search / list / info / read / create / update / upload / download / copy / move / rename, plus `file`, `folder`, `block`-level editing and `comment` (list / create / reply / create-inline). Authoring supports both DocxXML and a JSONML format with a v2 schema validator (`docjsonml/jsonml-schema-v2.json` + `doc_jsonml_validate_v2.go`). Document export and OA alignment land here.
- **`dws wiki` — knowledge base management** (`internal/helpers/wiki.go`, `internal/helpers/wiki_proxy.go`) — knowledge space `create` / `get` / `list` / `search` and member `add` / `list` / `update`, routed through a wiki proxy server.
- **`dws aiapp` — AI application lifecycle** (`internal/helpers/aiapp.go`) — `create` (with prompt / attachments / skills), `query` by task ID, `modify` by thread ID.
- **`dws aitable` forms + import/export** (`internal/helpers/aitable_form.go`, `internal/helpers/aitable_export_import.go`) — datasheet form management and full record import/export, the latter driven through the async-task helper for large datasets.
- **Reworked `chat` / `report` / `todo` / `contact` / `mail` command trees aligned to the Wukong baseline** (#355; `internal/compat/mail_hooks.go`, `internal/compat/todo_hooks.go`, `internal/helpers/report_readable.go`) — mail and todo gain dedicated compat hooks; `report` gains a human-readable rendering path alongside the raw JSON, plus deprecation shims for the old report shape.
- **`dws auth export` / `dws auth import`** (#357) — portable auth bundle for migrating Linux sandbox credentials. Exports the encrypted keychain (`~/.local/share/dws-cli`, including `auth-token.enc` and `dek`) plus required `~/.dws` config so refresh tokens survive import; copying only `app.json` leaves access tokens expiring after ~2 hours. Supports `-o` / `-i` tar.gz paths and `--base64` for copy/paste between sandboxes. `dws auth status` now shows refresh-token validity in table output.
- **Async-task and paging infrastructure** (`pkg/asynctask/`, `pkg/paging/`) — shared helpers underpinning long-running operations (e.g. aitable import/export, doc export) and cursor/page traversal.
### Changed
- **`envelope` now registers `cli.Aliases` as cobra aliases** (#391) — discovery-generated commands expose their declared aliases natively in the command tree, with accompanying command-structure and JSON-parsing cleanups.
- **Breaking: `dws pat chmod` prints a compact authorization summary by default, and gains batch authorization flows** (#389; `internal/pat/chmod.go`) — scripts that parse the raw MCP JSON from stdout must now pass `--format json` or `--verbose` to keep the machine-readable payload; the default summary keeps grant status, agentCode, grantType, scope counts, and a next-action hint. New batch grant/plan flows (`pat.batch_grant` / `pat.batch_plan`) authorize multiple products in one session, fall back to the legacy single-grant path when the server reports `PAT_BATCH_AUTH_UNSUPPORTED`, use the server's default `agentCode` when none is given, and surface per-tool authorization metadata for grant planning.
- **Skill packs synced to the Wukong-aligned content** across attendance / calendar / minutes / oa / sheet and others (#391).
## [1.0.32] - 2026-05-25
Two user-visible regressions resolved plus two AI-agent discoverability fixes. `dws drive upload` was returning `HTTP 403 SignatureDoesNotMatch` for any file whose MIME detects to a non-empty value — basically every real file — because the helper added a client-side `Content-Type` fallback whenever `drive.get_upload_info` returned an empty headers map. DingTalk drive's OSS presigned PUT URLs are signed against an empty `Content-Type` at signing time, so any client-supplied header makes the signature OSS recomputes diverge from the server-signed one, and the PUT is rejected (#347). On Apple Silicon, `dws upgrade` was aborting at the "解压并验证" step with `signal: killed` because GoReleaser cross-compiles `darwin/arm64` binaries on `ubuntu-latest` with no codesign step, and macOS 11+ `amfid` SIGKILLs unsigned arm64 binaries on first exec (#339) — the release pipeline now ad-hoc signs every darwin tarball, and the upgrade client self-heals if it ever encounters an unsigned binary again. On the AI-agent discoverability side, `dws aitable attachment upload-file` (the one-shot prepare + PUT + commit composite) is no longer hidden from `--help` — agents that only browse the command tree were getting stuck at the prepare-only `attachment upload` step, which returns an upload URL + fileToken but doesn't actually upload. And `dws --help` itself now surfaces the missing-command upgrade hint that the custom `renderRootHelp` had been silently dropping from cobra's `root.Long`.
### Added
- **`dws aitable attachment upload-file` is now visible in `dws aitable attachment --help`** (#347, `internal/helpers/aitable.go`) — the hardcoded one-shot composite (prepare + HTTP PUT + commit, returns `fileToken` directly) was previously marked `Hidden:true` and only reachable by agents that read `skills/references/products/aitable.md`. Agents that only discover commands via `--help` were getting stuck at the sibling envelope-generated `attachment upload` (prepare-only): they'd receive `uploadUrl` + `fileToken`, have no idea how to consume the URL, and either write the URL into the attachment field as if it were a token (wrong shape — the field expects `[{"fileToken":"ft_xxx"}]`) or fall back to "please use the UI" messages, which made `dws` look broken even though the capability was fully implemented. Unhiding mirrors the discoverability pattern `lark-cli base +record-upload-attachment` already follows. `Short` is tightened to explicitly mention the 3 steps it bundles; `Long` calls out the prepare-only sibling and recommends `upload-file` as the default for AI agents. The sibling `attachment upload` (prepare-only) keeps its envelope-generated registration but gets a new `Long` that states it is only step 1 of a 3-step flow, lists what an agent must do after (HTTP PUT to `uploadUrl`, then write `[{"fileToken":"ft_xxx"}]` into the attachment field), and points to `upload-file` as the recommended one-shot alternative. `TestAITableUploadFileCommandIsDiscoverable` in `internal/helpers/aitable_upload_file_test.go` guards against re-introducing `Hidden:true`.
- **`dws --help` root output now surfaces the `dws upgrade` hint when no listed command fits** (#347, `internal/app/root.go` + `internal/app/root_help.go`) — `root.Long` is set to `"提示: 如果遇到能力缺失、命令报错、新功能未注册、或无法完成任务, 请先用 'dws upgrade' 升级到最新版本后再试. 钉钉 OpenAPI 和 dws CLI 持续迭代, 新能力和 bugfix 会先在新版本上线."`. The custom `renderRootHelp` (which replaces cobra's default template to render the services / utilities sections) had been silently dropping `root.Long`; restoring it costs one `Fprintln` after the command list, separated by a blank line. The natural failure mode for both agents and users staring at `dws --help` is to give up or hack around when none of the listed commands fit — but in many cases the right action is simply `dws upgrade`, because new capabilities and bugfixes ship continuously and a missing command is usually a stale-binary issue. `TestRenderRootHelpIncludesLong` in `internal/app/visibility_test.go` uses a sentinel `Long` string and asserts the rendered output contains it verbatim, so any future rewrite of the help renderer that drops `Long` fails this test immediately.
### Fixed
- **`dws drive upload` no longer fails with `HTTP 403 SignatureDoesNotMatch` on any non-empty MIME type** (#347, `internal/helpers/drive.go`) — `httpPutDriveFile` was setting `req.Header["Content-Type"] = fallbackMIME` whenever the prepare_upload response returned an empty headers map. DingTalk drive's OSS presigned URLs sign `StringToSign` against an empty `Content-Type` at signing time, so any client-side header makes the signature OSS recomputes at PUT time differ from the server's presignature, and the upload is rejected with `403 SignatureDoesNotMatch`. This broke every `dws drive upload` for any file whose MIME detects to a non-empty value (`image/png`, `application/pdf`, every common binary) — i.e. essentially every real upload. Fix: drop the `hasContentType` / `fallbackMIME` path entirely, trust the server's headers map as authoritative; empty map means "no client-side headers needed", do not infer. `httpPutDriveFile`'s signature loses the `fallbackMIME` parameter. Manual verification: `curl -X PUT -H "Content-Type:" --data-binary @file <same-presigned-url>` returns `HTTP 200`, proving the only difference was the client-side `Content-Type`. `TestHttpPutDriveFile_NoContentTypeWhenServerHeadersEmpty` guards the empty-map path; `TestHttpPutDriveFile_PassthroughServerHeaders` guards that server-provided `Content-Type` / `x-oss-*` headers are forwarded verbatim. Important: `internal/helpers/aitable.go`'s `upload-file` helper deliberately keeps its `Set("Content-Type", mimeType)` call — its OSS endpoint uses a different signing mode (server includes the client-declared MIME in the signature, verified across 12 file types — all succeed). The two helpers must not be unified without re-validating both endpoints.
- **`dws upgrade` no longer dies with `signal: killed` on Apple Silicon after fetching the new binary** (#339) — GoReleaser cross-compiles `darwin/arm64` binaries on `ubuntu-latest` with no codesign step, and macOS 11+ on Apple Silicon requires at least an ad-hoc signature on every arm64 binary; `amfid` SIGKILLs unsigned arm64 binaries on first exec, which the upgrade client surfaces as `signal: killed` and aborts at the "解压并验证" step. Two layers of fix:
- **Release-side ad-hoc signing** (`scripts/release/post-goreleaser.sh` + `.github/workflows/release.yml`) — after GoReleaser produces the per-platform tarballs, `post-goreleaser.sh` unpacks each `dws-darwin-*.tar.gz`, applies an ad-hoc signature (`codesign --force --sign -` locally, `rcodesign` in CI), deterministically repacks the tarball, and rewrites the matching line in `checksums.txt` so the checksum stays consistent with the resigned tarball. `release.yml` installs `rcodesign 0.27.0` before GoReleaser runs. Every 1.0.32+ tarball ships signed; the install regression is fixed at the source.
- **Client-side self-heal in `validateNewBinary`** (`internal/app/upgrade.go`) — when running the freshly-extracted binary returns `signal: killed` on darwin, the validator retries once after running `codesign --force --sign -` on the binary and clearing the `com.apple.quarantine` xattr. This keeps `dws upgrade` working even if a future release ever skips the signing step again, and covers users upgrading from older unsigned binaries. `internal/app/upgrade_test.go` (+80 lines) covers the retry path end-to-end: a stripped binary exits 137 on first exec, `validateNewBinary` recovers via ad-hoc sign + xattr clear, the final binary shows `Signature=adhoc` and runs.
## [1.0.31] - 2026-05-21
Closes the last drive-surface gap with the Wukong edition: `dws drive upload` lands as a single-shot composite (`drive.get_upload_info` → HTTP PUT to OSS → `drive.commit_upload`) so a local file reaches DingTalk drive in one CLI invocation, no manual three-step orchestration. Two more drive commands — `dws drive list-spaces` (list visible drive spaces) and `dws drive delete` (delete a drive file, routed via `serverOverride` to the doc MCP server) — ship via the portal envelope; `dws cache refresh` once to pick them up. Companion skill docs teach the agent to recognise dingpan URLs of the form `alidocs.dingtalk.com/document/edit?dentryKey=…` / `…/document/preview?dentryKey=…` and pass the whole URL through to `--node` instead of trying to extract `dentryKey` by hand (the server interprets `dentryKey` and a bare `nodeId` differently — manual extraction was failing).
### Added
- **`dws drive upload --file <path> [--folder <dentryUuid>] [--space-id <id>] [--file-name <name>] [--mime-type <type>]`** (#335, see `internal/helpers/drive.go`) — composite leaf that runs the full three-step upload internally:
1. `drive.get_upload_info` — fetch the OSS-signed `resourceUrl` + `uploadId` + per-URL headers.
2. HTTP `PUT` the file binary to OSS (10-minute timeout, attaches every header returned by step 1).
3. `drive.commit_upload` — register the new file under the target space / folder.
`--dry-run` prints the three step invocations as a single JSON payload without making any network calls. `--file -` is rejected on purpose: this is a local-path upload, not stdin streaming. `--folder` only accepts a `dentryUuid`; pure-numeric values are rejected up front (`validateDriveParentID`) so callers don't accidentally pass a chat-link `dentryId` (a different ID namespace) where the drive API expects a `dentryUuid`. Response normalisation handles all the wrapper shapes the upstream returns — `content` / `result` envelopes, `resourceUrls[]` arrays, and the flat `resourceUrl` / `uploadUrl` fallbacks — so the composite produces a stable JSON shape regardless of which path the upstream takes. The helper only registers `upload`; the existing six envelope-generated leaves (`list` / `info` / `download` / `mkdir` / `upload-info` / `commit`) keep flowing through dynamic discovery unchanged. `pickCommands.MergeHardcodedLeaves` guarantees dynamic leaves win on collision, so this helper only fills the upload gap.
- **`dws drive list-spaces` and `dws drive delete` (envelope rollout)** (#335, ships via portal envelope) — `list_spaces` registers as a plain `cliName` alias on the existing drive MCP server; `delete_document` registers with `serverOverride: doc` so the call routes to the doc MCP server (which owns the delete API), surfacing under the drive command tree for ergonomics. **Existing users must run `dws cache refresh` once** to pick up these two new leaves; no binary upgrade is required for them, but they pair naturally with the v1.0.31 client that ships `upload`.
- **`skills/references/url-patterns.md`** (#335) — single authority for dispatching `alidocs.dingtalk.com` URLs across doc / sheet / wiki. Five-way split: `/i/p/<token>` short links → expand via `doc info`; `/i/nodes/<id>` node URLs → probe with `doc info` and route by `contentType` / `extension` / `nodeType`; `/spreadsheetv2/...` → `sheet`; `/document/edit|preview?dentryKey=<key>` (dingpan format) → pass the whole URL to `--node`, do not strip `dentryKey` by hand; `/i/share/...` (read-only share) → use the `read_url` fallback. The "URL precheck" Step 0 in `skills/SKILL.md` now redirects every URL-bearing prompt through this dispatcher before the agent picks a product.
### Changed
- **`skills/references/products/doc.md` — `--node` accepts dingpan URLs end-to-end** (#335) — `dws doc info` / `dws doc read` examples gain two extra rows showing `--node "https://alidocs.dingtalk.com/document/edit?dentryKey=<KEY>"` and `…/preview?dentryKey=<KEY>` as first-class `--node` inputs. The "URL recognition & DOC_ID extraction" table adds the `document/edit|preview?dentryKey=<key>` row, and the extraction rules are split into three explicit clauses so the agent stops manually pulling `dentryKey` out of the URL and feeding it as a bare `nodeId` (which the server rejects). The "nodeId dual-format note" upgrades to "nodeId multi-format note" with four equivalent `--node` input shapes side by side.
## [1.0.30] - 2026-05-19
Aligns the open-source CLI with the IM envelope and schema-pipeline plumbing the Wukong edition has been running in pre-prod, plus three user-visible quality-of-life fixes. The most visible one: chat-bot webhook payloads carrying literal Chinese mentions (`@所有人 周报来了` / `@张三 看一下`) no longer fail with `file not found` — `@` is only treated as the `@<filename>` file-injection prefix when followed by an ASCII path-shaped character. The `chat` command tree is refactored to lean on the service-discovery envelope: thin wrappers (`chat search`, `chat group rename`, `chat group members list/add/remove/add-bot`, `chat bot search`) move out of the hardcoded helper and become envelope-generated dynamic commands; the helper keeps only the chat commands with real business logic (intelligent routing, current-user resolution, response normalization, stdin/@file input). A new `dws chat message reply` joins the existing `send` / `send-by-bot` / `recall-by-bot` / `send-by-webhook` family. Underneath: `transform: invert_bool` lets envelopes flip boolean semantics between CLI surface and MCP body (e.g. `--off` ↔ `mute=true`); the pipeline executor fail-fast on upstream `content.errorCode` instead of polling forever; service-discovery dedup keeps two envelope entries that share an MCP endpoint but declare different `cli.id` as separate descriptors (so the `bot-root` / `bot-message` / `bot-group` trio fronting one MCP server stays as three distinct CLI command roots); and `dws chat` no longer nests as `dws chat chat` when two envelope servers both declare the same top-level command name.
### Added
- **`transform: invert_bool` for envelope flag overrides** (#317, see `internal/compat/transform.go`) — flips a boolean at send time. Strings `true`/`1`/`yes`/`on` → `false`; `false`/`0`/`no`/`off`/`""` → `true`. Used when the CLI surface and the MCP body have opposite semantics — e.g. envelope declares `--off` on the CLI but the MCP parameter is `mute=true` for "muted". The framework flips at send time so the envelope keeps the natural CLI verb without forcing every caller to remember the inverted mapping. Coverage in `internal/compat/transform_test.go`.
- **`dws chat message reply`** (#317, see `internal/helpers/chat.go`) — reply to a chat message. Sits alongside `send` / `send-by-bot` / `recall-by-bot` / `send-by-webhook` under `dws chat message`.
### Changed
- **`chat` command tree refactored to lean on the service-discovery envelope** (#317, commit `6be1247`) — `internal/helpers/chat.go` now only carries the chat commands that need real business logic on top of the raw MCP call: `chat message send` (current-user resolution + symmetric direct/group title validation), `chat message send-by-bot` / `recall-by-bot` / `send-by-webhook` (bot routing + stdin/@file input), and `chat group create` (response normalization). The thin wrappers — `chat search`, `chat group rename`, `chat group members list/add/remove/add-bot`, `chat bot search` — are now produced by the envelope as dynamic commands. Net diff in the helper: `+358 / -71` overall (re-aligning to envelope-owned chat structure), and `chat_test.go` drops 71 lines of test-stubs the dynamic path covers natively. Every previously documented chat command keeps the same flag set and the same MCP tool routing — the surface is just sourced differently.
- **Pipeline executor fail-fast on `content.errorCode`** (#317, see `internal/compat/pipeline.go`) — when an upstream tool returns a non-empty `content.errorCode`, `executePipelineCall` raises a validation error immediately with the upstream `errorMessage` instead of proceeding into the poll/download phase. Pre-execution cobra validation (`MarkFlagRequired`) only checks that a flag was set, not that its value was non-empty — so a `--required-flag ""` reaches the upstream tool and the upstream rejects with `errorCode`. Without the short-circuit the pipeline kept polling for a task ID that would never exist, either spinning to `PollTimeout` or burning through retries with no actionable error. Exit code 2 (validation), same as any other CLI-layer pre-flight rejection.
- **Service-discovery dedup keys now include `cli.id`** (#317, see `internal/market/registry.go`) — `NormalizeServers` used to dedup envelope entries by endpoint alone (and by `displayName` in the second pass), which collapsed envelope entries that intentionally split one MCP endpoint into multiple CLI command trees. The `bot-root` / `bot-message` / `bot-group` trio all front the same `.../server/4717...` MCP endpoint and share the displayName `机器人消息`, but each declares a distinct `cli.id` and a distinct CLI command root; the old dedup kept only the last-write and dropped two of them. The dedup key now appends `#<cli.id>` when present, falling back to endpoint / name when absent so historical envelopes without `cli.id` keep their existing behaviour. Coverage in `internal/market/registry_test.go`.
### Fixed
- **`@<text>` injection no longer eats Chinese mentions like `@所有人` / `@张三`** (#317, see `internal/cli/stdin.go`) — `ReadFileArg` and `ResolveInputSource` used to treat *any* value starting with `@` as the `@<filename>` injection syntax. Chat-bot webhook payloads commonly contain literal mentions, so `dws chat message send-by-bot --text "@所有人 周报"` was failing with `file not found: 所有人 周报` before the message reached the API. The new `looksLikeFilePath` heuristic accepts `@` followed by an ASCII path-prefix character (`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`), or `@-` for stdin, and passes the value through unchanged otherwise. `@A 但接下来都是中文@测试` *does* still attempt a file lookup because the rune right after `@` is ASCII — this matches the documented `@<path>` prefix shape. The historical "bare `@` is an error" behaviour is preserved. Coverage in `internal/cli/stdin_test.go::TestReadFileArgChineseAtMention`.
- **`dws chat` no longer nests as `dws chat chat` when two envelope servers contribute the same top-level command** (#317, see `internal/compat/dynamic_commands.go`) — `BuildDynamicCommands` used to overwrite `topLevel[name]` on the second contribution and rely on `attachOrMerge` later, which then attached the *whole* incoming command (named `chat`) under the existing root, producing `dws chat chat <leaf>`. The new `mergeSubcommandsInto` moves the second contribution's *children* under the first root and drops the duplicate wrapper, so e.g. `group-chat` + `im` envelopes that both declare `cli.command: chat` produce a single flat `dws chat` subtree.
- **Multi-server tool-name authority correction in the runtime runner** (#317, see `internal/app/runner.go` + `internal/app/direct_runtime.go`) — when two envelope servers share the same `cli.command`, the per-product endpoint map `endpoints[cmd]` in `registerDynamicServer` is second-writer-wins, and `catalog.FindProduct` may return the wrong server's endpoint for a tool whose real owner is the *other* server. `runtimeRunner.Run` now cross-checks the canonical tool→endpoint map exposed by the new `directRuntimeToolEndpoint`: when the per-tool endpoint exists and differs from the per-product endpoint the catalog returned, the tool-owner endpoint wins. Pairs with the registry dedup change above so the routing matches the dedup result.
## [1.0.29] - 2026-05-17
Three discovery-envelope products land on the open-source surface — `aiapp` (AI applications), `live` (DingTalk live streaming), and `aisearch` (enterprise people search) — closing the gap with the Wukong edition's product list. The `aisearch` envelope ships rich model-tolerance affordances (short flags, flag aliases, subcommand aliases) so AI agents that hallucinate keyword synonyms (`--query` / `--name` / `--q` / `--text` / `--find`) or alias subcommands (`search` / `find` / `query` / `user` / `people` / ...) still route to the canonical `person` tool instead of erroring out. To support that final fragment of agent tolerance, `internal/compat/registry.go` relaxes the envelope-generated leaf command's `Args` validator from `cobra.NoArgs` to `cobra.ArbitraryArgs` — restoring cobra's own default (`legacyArgs` returns nil for leaves) so trailing positional words are silently ignored. Plus the previously-shipped credential-isolation fix.
### Added
- **`dws aiapp` / `dws live` / `dws aisearch` — three new products discovered via envelope** (no public issue; pre-Diamond rollout) — open-source `dws` now exposes:
- **`dws aiapp`** — AI application lifecycle: `create --prompt <p> [--attachments <json>] [--skills <csv>]` / `query --task-id <id>` / `modify --prompt <p> --thread-id <id> [--skills <csv>]`. Backed by upstream `create_ai_app` / `query_ai_app` / `modify_ai_app` MCP tools.
- **`dws live stream list`** — list my DingTalk live streams. Backed by upstream `get_my_lives`.
- **`dws aisearch person`** — enterprise people search by keyword + multi-dimension filter. Dimensions: `all` (default) / `name` / `department` / `position` / `duty` / `supervisor` / `subordinate` / `phone` / `jobNumber` — multiple comma-separated (`--dimension name,department`). Backed by upstream `enterprise_person_search`.
- The `aisearch` envelope additionally registers `-w` / `-d` short flags (keyword / dimension); hidden flag aliases `--query` / `--name` / `--q` / `--text` / `--find` all routing to `keyword`; and cobra subcommand aliases `search` / `find` / `query` / `user` / `people` / `search-person` / `search-user` / `user-search` / `lookup` / `ask` / `contact` all routing to `person`. This closes the F-class model-tolerance regression cases in `dws-wukong/auto-test/cli_to_mcp/testcases/aisearch/test_90_aisearch_param_regression.py` (50/50 pass for aiapp + live + aisearch on the pre-mcp build).
- **Users must run `dws cache refresh` once** to pick up the new envelopes; no binary upgrade is required, but pairs naturally with the v1.0.29 client (see Fixed below for the envelope-leaf-Args change).
### Fixed
- **Envelope-generated leaf commands now tolerate trailing positional args** (#306, no public issue) — `NewDirectCommand` in `internal/compat/registry.go` was hard-coding `cobra.NoArgs` for leaves without positional bindings (`totalMax == 0`). This is stricter than cobra's own `legacyArgs` (cobra `args.go:30-32` returns `nil` for any command without subcommands), and surfaced as `unknown command "<word>" for "<leaf>"` whenever an AI agent passed trailing positional words after a leaf — e.g. `dws aisearch person search --keyword "张"` or `dws aisearch person user search --keyword "张"`. Switching the `totalMax == 0` branch (and the initial value) from `cobra.NoArgs` to `cobra.ArbitraryArgs` restores cobra's natural leaf behavior: trailing positional args are silently ignored. Existing positional-binding paths (`MinimumNArgs` / `RangeArgs` / `MaximumNArgs`) are unchanged. Verified against `dws-wukong/auto-test/cli_to_mcp/testcases` — aiapp (9/9) + live (3/3) + aisearch (38/38) = **50/50** pass, vs 48/50 before this patch.
### Security
- **App credential files are partitioned by edition to prevent cross-edition credential leakage** (#300, no public issue; found during internal review) — different `dws` editions sharing the same config directory previously read and wrote the same `app.json`. A sibling edition that pinned its OAuth client ID could persist that ID through the shared post-login path, and the open-source build could later adopt it from the same file. Open-source/empty edition keeps the legacy `app.json` path for compatibility; sibling editions now use `app-<edition>.json`, matching the existing cache partitioning strategy. This prevents new cross-edition app credential writes and reads from colliding. After a sibling edition saves its new partitioned file, it also best-effort removes a legacy `~/.dws/app.json` only when that file's `clientId` matches the sibling edition being saved; a different, unparsable, or otherwise unowned `app.json` is left untouched to avoid deleting open-source credentials. If you previously ran multiple editions in one shared `~/.dws`, remove any confirmed-stale orphan manually with `rm ~/.dws/app.json` after verifying it is not the open-source credential file you still need.
## [1.0.28] - 2026-05-14
A single symmetric follow-up to 1.0.26's #250: `dws chat message send --group <cid>` now refuses an empty `--title` at the CLI layer instead of letting the call fall through to the API and surface a misleading `发群服务窗会话消息失败` error. No other behaviour changes.
### Fixed
- **`dws chat message send` rejects missing `--title` on group messages** (#294, completes #250) — `send_message_as_user`'s schema marks `title` as required (just like `send_direct_message_as_user`), but `buildChatMessageSendInvocation` only had the pre-validation on the direct-message branches. Group sends without a title were falling through to the API and returning the same misleading `发群服务窗会话消息失败` that #250 already fixed for direct messages. The check now covers both branches: missing `--title` on `--group` returns `--title is required for group messages (--group)` with exit code 2; missing on `--user` / `--open-dingtalk-id` keeps the original `--title is required for direct messages (--user / --open-dingtalk-id)`. The `Long` help, `--title` flag description, the first `Example`, and `skills/references/products/chat.md` (including the drive→chat workflow example) are realigned to "title is required for both direct and group messages" — the docs previously contradicted themselves (the prose said 群聊可选 while the flag listing said 必填). `internal/helpers/chat_test.go` adds a `group-without-title` rejection case; the existing `group` / `positional-text` success cases now pass `--title` to stay aligned with the new validation. No API request shape change — the server has always required `title`; the CLI now matches.
## [1.0.27] - 2026-05-14
Two user-visible fixes plus the schema primitive they're built on. `dws doc update` now reads Markdown from a file or stdin, so long / multi-line / table-heavy content no longer gets mangled by shell escaping; `dws sheet find --query` stops returning `unknown flag` on the open-source build, restoring copy-paste from internal wukong docs. Underneath, schema/discovery envelopes get a generic `file_read` transform and a `CLIFlagOverride.MapsTo` field that lets two sibling CLI flags route into the same MCP parameter slot. Also suppresses a noisy WARN on normal stdio-plugin shutdown.
### Added
- **`file_read` transform + `CLIFlagOverride.MapsTo` field** (#291, closes #277 #278 #282 #288) — discovery envelopes can now declare a path-typed CLI flag that performs the "file path → file contents string" conversion client-side before the value reaches the upstream MCP parameter.
- `transform: "file_read"` (`internal/compat/transform.go`) — reads the file at the flag's value with UTF-8 validation; `-` means stdin. Any IO / encoding failure is surfaced as a validation error (exit 2), distinct from the generic transient-failure path (exit 1).
- `CLIFlagOverride.MapsTo` (`internal/market/registry.go`) — redirects the flag's final value (post-transform or literal) into a named MCP parameter slot instead of the default `params[propertyName]`. This lets a single MCP parameter (e.g. `markdown`) be fed by two sibling CLI flags — a literal `--content` and a file-reading `--content-file` — paired with the existing tool-level `MutuallyExclusive` / `RequireOneOf` to express "exclusive, at least one".
- Wired into the `internal/compat/dynamic_commands.go` normalizer via a separate `mapsToRoutes` collection + routing pass; empty `MapsTo` preserves the legacy `params[propertyName] = value` semantics, so every pre-existing dynamic_commands test passes unchanged. Pre-prod end-to-end verified across 6 cases (see PR #291's Validation table).
- **`dws doc update --content-file <path>` (envelope rollout)** — fixes "long Markdown can't reach the doc". The old command only accepted `--content "..."`, so long / multi-line / table-heavy Markdown got mangled by shell escaping and AI agents writing >2KB of content were stuck. The envelope now maps both `--content` (literal) and `--content-file` (`file_read` transform) to the `markdown` parameter, makes them mutually exclusive via cobra's `MarkFlagsMutuallyExclusive`, and requires at least one via `RequireOneOf`. `--content-file -` reads from stdin, so `cat long.md | dws doc update --content-file -` works directly. **Existing users must run `dws cache refresh` once** to pick up the new envelope.
- **`dws sheet find --query` hidden alias (envelope rollout)** — fixes "unknown flag when copy-pasting commands across editions". Users copying `dws sheet find --query "..."` from internal wukong docs onto open-source `dws` got `unknown flag: --query`, because the open-source primary flag is named `--find`. The envelope now registers `--query` as a hidden alias of `--find` via `CLIFlagOverride.Aliases` (the field shipped in 1.0.26) — it doesn't show up in `--help`, but accepts values and writes to the same MCP parameter. `--find` behaviour is unchanged. Also requires `dws cache refresh` once.
### Fixed
- **Noisy `failed to stop stdio client: exit status 1` WARN on normal stdio-plugin shutdown** (#285) — when `Stop()` explicitly `Kill`s the subprocess, the non-zero exit code returned by `cmd.Wait()` is expected behaviour, but it was being propagated as an error and logged to stderr on every CLI exit, polluting agent log parsing. `Stop()` now returns `nil` after Kill + Wait; the error path is reserved for "process exited on its own with non-zero" (e.g. stdin close without an explicit Kill). `internal/transport/stdio.go` + `stdio_integration_test.go` assert "Stop() returns nil after kill".
## [1.0.26] - 2026-05-12
Platform-stability round: Windows PAT-auth browser opener no longer truncates URLs at `&userCode=`, macOS sandbox hosts get an opt-in keychain fallback, and `dws doc download` rejects `axls` nodes before requesting `drive:download` consent. Two new global output formats `-f ndjson` and `-f csv` (matching `larksuite/cli`) land as first-class citizens with real-traffic-verified list detection. The `dws doc comment *` regression tracked in #240 is also resolved — fix is in the market metadata, users just need `dws cache refresh` once.
### Added
- **`-f ndjson` and `-f csv` global output formats** (#259, closes #252) — `ndjson` emits one compact JSON record per line (works straight with `jq -c` / `while read` / log pipelines); `csv` goes through `encoding/csv` (RFC-4180 — quoting, embedded newlines, CJK all handled by stdlib) and reuses the existing `-f table` column resolver (`normalizePayload` / `unwrapPrimaryObject` / `extractRowsFromMap` / `rowsFromSlice` / `formatValue`) so table and csv stay visually aligned. After a 7-product real-traffic sweep (contact / chat / doc / mail / todo / minutes / schema), the `preferredListKeys` whitelist was extended to cover the actual DingTalk envelope shapes — `contact user search` (`result`), `chat search` (`result.value`), `doc search` (`documents`), `mail mailbox list` (`emailAccounts`), `todo task list` (`result.todoCards`) — so these commands now degrade into a proper row stream instead of collapsing to a single-line `key,value` blob. Lives in `internal/output/ndjson.go` + `internal/output/csv.go`; `--format` help in `internal/app/flags.go` now lists `ndjson|csv` alongside `json|table|raw|pretty`.
### Changed
- **Sticky flag splitting is now schema-aware** (#272) — PreParse `StickyHandler` 此前会把任何前缀命中已知 flag 的 `--flagsuffix` 一律切成 `--flag suffix`,于是 `--starttime20260507` 这类拼错被静默改写成 `--start time20260507`,把假值传到下游。新行为按 flag 的 pflag 类型 / JSON Schema `format` / `enum` 校验 suffix 是否像合法 value(共享逻辑见 `pkg/cmdutil/sticky_suffix.go`),不像就保留原 token 让 cobra 报 `unknown flag`。slice/array/object 类型的 flag 永不切分。首 rune 读取使用 `utf8.DecodeRuneInString`,对中文等多字节 value 安全。
### Added
- **`available_flags` field on unknown-flag errors** (#272) — `dws -f json` 的 unknown-flag 错误体里新增 `available_flags`(已排序、过滤掉 hidden 与内部 `json` / `params`),方便 agent 不解析 `--help` 就能恢复。Human-readable 输出会附 `Flags: ...` 行,截断在 200 字节内。
### Fixed
- **`dws chat message send` 单聊缺 `--title` 时前置校验** (#250) — 单聊(`--user` / `--open-dingtalk-id`)的底层工具 `send_direct_message_as_user` 在 API 层强制要求 title,缺失时返回误导性的 `发群服务窗会话消息失败`。CLI 现在在 `buildChatMessageSendInvocation` 里前置校验,直接返回 `--title is required for direct messages (--user / --open-dingtalk-id)`;同时把 `Long` help、`--title` flag 描述、Example 和 `skills/references/products/chat.md` 全部对齐为「单聊必填,群聊可选」。群聊行为不变。
- **PAT auth URLs were truncated on Windows browser open** (#242, fixes #230) — `cmd /c start <url>` on Windows interprets `&` as a command separator, so PAT URLs containing `&userCode=...` were silently chopped before the userCode segment, and the browser landed on a 0-permission DingTalk page. The retry opener now uses `rundll32 url.dll,FileProtocolHandler`, which passes the URL through verbatim. The PAT response also exposes a copy-safe `data.authorizationUrl` (in addition to the service-provided `data.uri`, which is preserved as-is), and human-readable PAT output prints `PAT_AUTHORIZATION_URL=<full-url>` on its own line so OpenClaw-style host wrappers that swallow or reformat stderr can still capture the full link. Legacy DingTalk hash-route shapes (`https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId=...%26userCode=...`) are normalised back into the working `/fe/old?hash=...#/personalAuthorization?...&userCode=...` form. Regression tests cover the issue-shaped URLs (encoded hash, fragment, `&userCode`) plus the OpenClaw malformed-hash variant.
- **`dws doc download` triggered `drive:download` PAT consent for unsupported axls nodes** (#268, fixes #190) — added a `get_document_info` preflight before `download_file`, so online-sheet (`axls`) nodes are rejected locally with guidance to use sheet range tools instead. The preflight reads `extension` from deterministic response paths (no recursive payload scan) and routes its own PAT errors back through `handlePatAuthCheck`, preserving device-flow / host-owned PAT behaviour. Costs one extra MCP roundtrip per `doc download` — deliberate, so the unsupported path fails before consent. Lives in `internal/app/doc_download_preflight.go`; coverage in `internal/app/runner_test.go`.
- **macOS sandbox hosts (Codex App etc.) couldn't read/write tokens via Keychain** (#267, fixes #214) — sandboxed macOS environments intercept `security` / Keychain APIs, so every token operation failed. New opt-in `DWS_DISABLE_KEYCHAIN=1` switches macOS to the same file-DEK path Linux uses (DEK at `~/Library/Application Support/dws-cli/dek`, mode `0600`), bypassing the system Keychain. Default behaviour is unchanged — fallback is strictly opt-in because file-DEK is a weaker trust model than Keychain-managed storage (DEK file sits next to ciphertext in the same directory). The Darwin / Linux file-DEK implementation is now shared in `internal/keychain/file_dek.go` (Linux path deduplicated by ~40 lines). Documented in `docs/reference.md` (中英) with the security tradeoff spelt out so users make the choice explicitly.
- **`dws doc comment {list,create,create-inline,reply}` returned `PARAM_ERROR - 未找到指定工具`** (fixes #240, also #234) — the four comment tools used to live on an independent `doc-comment` MCP server. After the Portal merged comment functionality into the `doc` server descriptor, the runtime `tools/list` on the merged `doc` server didn't include them, so every `dws doc comment *` call returned the "tool not found" PARAM_ERROR. The market metadata for the `doc` server now declares `serverOverride: "doc-comment"` on all four comment `toolOverrides`, so the existing CLI routing path sends `dws doc comment *` to the still-running `doc-comment` MCP server (which has the tools). No CLI code change was required, but **existing users must run `dws cache refresh` once** to pick up the updated descriptor — without that, the stale local market cache keeps pointing the call at the merged `doc` server and the error persists. Verified post-refresh: dry-run resolves to `https://mcp-gw.dingtalk.com/server/doc-comment` with tool `list_comments`, real calls return normal business responses (e.g. legitimate cross-org authz errors) instead of `未找到指定工具`.
## [1.0.25] - 2026-05-11
Two generic envelope-schema enhancements that close gaps the `cli_to_mcp` test suite kept surfacing — both product-agnostic, no hardcoded helper commands. Plus missing skill references for the already-registered `sheet` and `wiki` products are now shipped.
### Added
- **`sheet` (在线电子表格) skill reference + product-overview entry** — the `sheet` product registers **34 envelope tools** covering worksheet CRUD (`create` / `new` / `list` / `info` / `copy_sheet` / `update_sheet`), range read/write (`range read` / `range update` / `append`), dimension ops (`add-dimension` / `insert-dimension` / `delete-dimension` / `move-dimension` / `update-dimension`), merge (`merge-cells` / `unmerge-cells`), find/replace (`find` / `replace`), filter views (`filter-view {create, list, update, delete, update-criteria, delete-criteria}`), sheet-level filters (`create_filter` / `get_filter` / `update_filter` / `delete_filter` / `set_filter_criteria` / `clear_filter_criteria` / `sort_filter`), image write (`write-image`), and async export (`submit_export_job` + `query_export_job`). These were live in the envelope but `skills/references/products/sheet.md` had not shipped and `skills/SKILL.md` 产品总览 didn't list `sheet`, so agents had no reference to consult and were skipping it during intent routing. This release adds the doc, registers `sheet` in 产品总览 + 意图判断决策树, extends `description` to include 在线电子表格, adds a Sheet row to `README.md` / `README_zh.md` "Key Services", and notes the v1.0.25 reality on naming (about a third of `sheet` tools still expose snake_case cli_names pending `CLIAliases` (#246) rollout) and on export (no consolidated `dws sheet export` exists in v1.0.25 — `submit_export_job` + `query_export_job` are the atomic primitives; Pipeline (#247) provides the future plumbing).
- **`wiki` (知识库) skill reference + product-overview entry** — the wiki product's 7 envelope tools (`wiki.create_wikiSpace`, `wiki.get_wikiSpace`, `wiki.list_wikiSpaces`, `wiki.search_wikiSpaces`, `wiki.add_member`, `wiki.list_member`, `wiki.update_member`, surfaced as `dws wiki space create / get / list / search` and `dws wiki member add / list / update`) have been registered for a while, but no `skills/references/products/wiki.md` shipped with them, so agents had no per-command reference to consult. This release adds the reference doc, registers `wiki` in `skills/SKILL.md`'s 产品总览 table and 意图判断决策树, mentions 知识库 in the skill `description` frontmatter, adds a Wiki row to `README.md` / `README_zh.md` "Key Services", and removes `wiki` from the "Coming soon" callout (which was now stale).
- **`CLIToolOverride.CLIAliases` envelope field** (#246) — lets a single MCP tool register additional cobra command aliases via envelope JSON (e.g. `range read` also accepts `range get`, `member list` accepts `member ls`). Plumbed through the existing `Route.Aliases → cobra.Command.Aliases` path; sibling conflicts are silently dropped by cobra. Lives in `internal/market/registry.go` + `internal/compat/dynamic_commands.go`.
- **`json_parse_strict` transform** (#246) — strict-JSON variant of `json_parse` that does **not** fall back to YAML. Use when the upstream tool requires a structured array/object and silently coercing a malformed input to a scalar string would mask a real user error (observed: `filter-view --criteria 'NOT_VALID_JSON'` was being accepted and quietly creating an empty-criteria view). In `internal/compat/transform.go`.
- **`CLIToolOverride.Pipeline` + pipeline executor** (#247) — a single CLI command can now orchestrate an ordered sequence of MCP tool calls plus optional HTTP-download sinks, declared entirely in envelope JSON. Motivating use case: the "submit-job → poll-status → download-result" pattern (e.g. sheet export) that previously required per-product hardcoded helpers.
- `PipelineStep` supports `type:"call"` (with optional `PollUntilField` / `PollUntilValue` / `PollIntervalSec` / `PollTimeoutSec` for polling loops) and `type:"download"` (resolves `DownloadURLField`, HTTP GETs the body, writes to the path from `OutputFlag`, infers filename for directory paths).
- Template language: `$flag.<name>` resolves a user CLI flag by alias; `$step.<idx>.<dotPath>` walks a prior step's response (works through wrapped MCP envelopes); literals pass through.
- `CLIFlagOverride.PipelineLocal` marks a flag as CLI-side only so `CollectBindings` skips it (value never reaches MCP params); the pipeline executor still reads it via `extractFlagValuesByAlias`.
- Download step emits machine-parseable plain-text lines (`jobId: <id>\n`, `downloadUrl: <url>\n`) alongside the standard JSON envelope, so shell pipelines and regex-based tests can extract key values without JSON parsing.
## [1.0.24] - 2026-05-09
Three small but user-visible safety/usability changes: the embedded distribution now refuses to self-upgrade, the `dws auth login` help text finally matches the actual default flow (loopback, not device), and the release workflow gains a manual fallback trigger.
### Changed
- **`dws upgrade` is blocked in embedded distributions** (#248) — when the CLI is shipped as an embedded asset (e.g. inside another product), `dws upgrade` would happily overwrite the host-managed binary. The upgrade entry point now detects the embedded build flag and exits early with a clear message; covered by `internal/app/upgrade_embedded_guard_test.go`.
### Docs
- **`dws auth login` help text reflects the real default** (#238, fixes #226) — the long help previously claimed "OAuth 设备流 (默认)", but the actual default starts a 127.0.0.1 loopback listener and only switches to device flow when `--device` is passed. SSH-into-headless-Linux users following the old text hit a dead end (remote-side 127.0.0.1 is unreachable from the local browser). Help and two `flagErrorWithSuggestions` messages in `root.go` are realigned: each method is named after its real flag (`OAuth Loopback 流 (默认)` / `OAuth 设备流 (--device)` / `直接提供 Token (--token)`), with an explicit `--device` example for SSH/headless. No behaviour change.
### CI
- **`workflow_dispatch` trigger added to release workflow as a fallback** (#261) — GitHub occasionally drops tag-push events; the release job can now be re-run manually against any tag ref without having to delete and re-push the tag.
## [1.0.23] - 2026-05-08
A single fix for HTTP proxy support across the CLI's custom HTTP transports. No behaviour changes elsewhere.
### Fixed
- **`HTTP_PROXY` / `HTTPS_PROXY` environment variables silently ignored by all custom transports** (#237, fixes #236) — the three custom `http.Transport` instances built by the CLI (`internal/transport/client.go` MCP transport, `internal/apiclient/client.go` DingTalk OpenAPI client, `internal/app/legacy.go` IPv4-forcing registry client) all set `DialContext` / `TLSClientConfig` / timeouts but omitted the `Proxy` field. Per Go's `net/http` contract, a non-nil Transport without an explicit `Proxy` means "no proxy" — env vars are silently ignored, breaking sandboxed or air-gapped deployments that route outbound through `HTTP_PROXY` / `HTTPS_PROXY`. All three transports now set `Proxy: http.ProxyFromEnvironment`.
### Tests
- Per-package regression test that pointer-compares the Transport's `Proxy` func against `http.ProxyFromEnvironment`, avoiding flakiness from Go's `envProxyOnce` memoisation when running alongside tests that read proxy env early. (#237)
## [1.0.22] - 2026-05-07
Two release-blocking bug fixes: `dws attendance summary` now exposes the server-required `--stats-type` flag (without it, every call returned C0002), and the install scripts finally populate `~/.hermes/skills/dws/` for users who already have Hermes.
### Fixed
- **`dws attendance summary` returned C0002 (统计类型错误) on every call** (#228, fixes #227) — the DingTalk MCP tool `get_attendance_summary` requires `statsType` at the business layer even though the schema marks it optional. The CLI did not expose any way to set it, so the command was 100% unusable. A new `--stats-type` flag (`week` / `month`) is now plumbed through to `QueryUserAttendVO.statsType`; the flag is documented as required in the long help, flag description, and `skills/references/products/attendance.md`.
- **Install scripts skipped `.hermes/skills/` when populating skill directories** (#221, fixes #188) — the `AGENT_DIRS` lists across `build/npm/install.js`, `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh` and the four upgrade-path mirrors (8 sources total once review feedback was addressed) did not include `.hermes/skills`, so users with Hermes installed were not getting `~/.hermes/skills/dws/` populated automatically. The existing parent-directory gate keeps this zero-side-effect for users without Hermes.
### Tests
- New `--stats-type` regression coverage in `test/cli_compat/attendance_test.go` — verifies `statsType` is written to `QueryUserAttendVO` when set to `month` or `week`, and is omitted when not provided. (#228)
## [1.0.21] - 2026-05-05
A single critical routing fix for `dws drive` commands. No new commands or behaviour changes elsewhere.
### Fixed
- **`dws drive mkdir` / `dws drive download` silently routed to the doc MCP server** (#220, fixes #219) — when two MCP servers register tools with the same name (e.g. both `drive` and `doc` expose `create_folder`), the tool-level endpoint map used last-writer-wins, so drive-side calls landed on the doc endpoint and returned mock-shaped responses (`success: true` with a fake `folderId`) without actually creating anything. `directRuntimeEndpoint` now resolves product-level first when the caller already knows the productID, and only falls back to the tool-level lookup when productID is empty. The wrong-server collision and the resulting "succeeded but didn't" behaviour are gone.
## [1.0.20] - 2026-05-04
Documentation polish and a login regression fix. No behaviour changes outside the login MCP refresh path.
### Fixed
- **Login no longer reuses stale `clientId` from an old MCP cache** (#213) — `dws login` now unconditionally re-fetches the MCP descriptor, so a previously cached client id can't keep producing auth errors after the server rotates it.
### Docs
- **`dws chat message list` pagination** (#218, fixes #195) — clarifies that `nextCursor` is opaque and must be passed back as `--cursor` exactly; warns against parsing or reusing it as an offset.
- **`dws contact search` examples** (#209) — switched from the removed `--keyword` flag to the current `--query`.
- **`dws todo` help text** (#205) — expanded field semantics so MCP wrappers generate accurate schemas.
- **`dws chat message send-by-bot` and `dws report create` help** (#217, #106, #107) — `--robot-code` / `--title` / `--text` now carry the `(必填)` marker; `report create --contents` documents the `key=field_name` requirement and rewrites examples as a `template detail → create` two-step pipeline.
- **CHANGELOG backfill for 1.0.19** (#204).
## [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.
### Added
- **`internal/compat` subcommand merging via `attachOrMerge`** — when two or more server entries attach to the same parent (e.g. `parent: "chat"`) and their `cli.command` collides with an existing subcommand in the parent's tree, the new subcommand's children are merged recursively into the existing one instead of creating a duplicate sibling. Leaf-name collisions resolve first-wins. Fixes the "double `group` / `message` rows in `dws chat --help`" symptom when bot capabilities are distributed across `chat.group.members` and `chat.message`.
- **`docs/command-index.md`** — a single, English, auto-generated listing of every runtime command the `dws` CLI exposes under the pre environment (159 total). Each entry carries a description and a "when to use" column aimed at AI agents. Replaces the earlier `command-index.pre.*` / `command-index.full.*` ad-hoc snapshots.
### Changed
- **README Key Services table** (`README.md` + `README_zh.md`) fully synced to the shipped command surface:
- `Chat`: 20 → **23** (bot capabilities merged in; new `list-all` / `list-focused` / `list-unread-conversations` / `conversation-info` exposed)
- `Calendar`: 13 → **14**
- `AI Tables`: 37 → **41** (chart / dashboard public-share config rows)
- `Doc`: 16 → **21** (comment subtree + `file create`)
- `Minutes`: 22 → **19** (single-tool `record`, `list query`, `list-by-keyword-range` pruned)
- New `Drive` row (6 commands) — promoted out of "Coming soon"
- `Workbench` row and standalone `Bot` row removed
- Total revised to **159 commands across 13 products**
- **Quick Start** expanded to 7 examples covering `doc`, `minutes`, `drive` in addition to `contact`, `calendar`, `todo`
- **Coming soon** trimmed to 5: `mail`, `conference`, `aiapp`, `live`, `wiki`
- **Reference & Docs** section now leads with a pointer to the new `docs/command-index.md`
- **Flag naming cleanup** — CLI flags across chat, calendar, drive, minutes, contact, and devdoc have been standardised so the names users type match the product-skill documentation. Notable flags:
- `dws contact user search` / `dws contact dept search` / `dws devdoc article search` now take `--query` (previously `--keyword`)
- `dws chat message list` / `dws chat message search` / `dws chat message list-mentions` / `dws chat conversation-info` / `dws chat message send` now take `--group` for the target conversation (previously `--id`) and `--open-dingtalk-id` (previously `--open-id`)
- `dws chat message list-by-sender` now takes `--sender-user-id` / `--sender-open-dingtalk-id` (previously `--user` / `--open-id`)
- `dws chat message list-topic-replies` now takes `--group` / `--topic-id` / `--limit` / `--time` (previously `--id` / `--topic` / `--size` / `--start`)
- `dws chat search-common` now takes `--match-mode` (previously `--mode`)
- `dws drive list` now takes `--max` / `--thumbnail` (previously `--max-results` / `--with-thumbnail`)
- `dws calendar event suggest` now takes `--users` / `--duration` / `--timezone` (previously `--attendee-user-ids` / `--duration-minutes` / `--time-zone`)
- `dws minutes list mine` / `dws minutes list shared` now take `--max` (previously `--max-results`) and gain `--query` / `--start` / `--end`
- `dws minutes list all` no longer exposes the legacy `--__scope__` internal alias
- **Flag coverage additions** — `dws calendar event create` / `update` gain `--attendees`, `--open-dingtalk-ids`, `--timezone`; `dws chat message send` gains file-message flags (`--dentry-id`, `--file-name`, `--file-size`, `--file-type`, `--media-id`, `--msg-type`, `--space-id`) plus `--open-dingtalk-id` / `--user`; `dws chat message list` gains `--open-dingtalk-id` / `--user`; `dws aitable table delete` gains `--reason`; `dws calendar participant add` gains `--optional`; `dws todo task create` gains `--recurrence`.
### Tests
- 3 new unit tests in `internal/compat/dynamic_commands_test.go`:
- `TestBuildDynamicCommands_ParentMergeSameName` — two servers with identical `command` + `parent` collapse into a single merged subcommand
- `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.
+236 -33
View File
@@ -21,7 +21,7 @@
> [!IMPORTANT]
> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Join the DingTalk DWS co-creation group for support and updates. See [Getting Started](#getting-started) below.
>
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws Open Source Community DingTalk Group QR Code" width="150">
<details>
<summary><strong>Table of Contents</strong></summary>
@@ -63,6 +63,27 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
```
<details>
<summary><strong>Skill mode: mono vs multi</strong></summary>
The installer ships skills in one of two layouts. CLI commands (`dws aitable ...`, `dws calendar ...`) are identical in both modes — only the agent-side skill layout differs.
| Mode | What gets installed | Best for |
|------|----------------------|----------|
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
| **multi** 🧪 **EXPERIMENTAL** | 18 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 18 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
How to pick:
- **Quick install** (one-liner above): non-interactive, installs `mono`.
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) mono 2) multi` (default 1).
- **Override via env**: `DWS_SKILL_MODE=multi curl -fsSL ... | sh`.
- **Switch later**: `dws skill setup --mode multi` (or `--mode mono`) — re-run any time.
</details>
<details>
<summary>Other install methods</summary>
@@ -92,6 +113,36 @@ cp dws ~/.local/bin/ # install to PATH
</details>
## China mirror
For users in mainland China, the following channels avoid GitHub network issues. By default (without setting these environment variables) the installer pulls from GitHub.
**1. Install script + pre-built binary (Gitee mirror):**
Repository mirror: `https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli`
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
```
> With `DWS_GITEE_REPO` set, the installer resolves the latest version and every release asset (binary, checksums, skills) from the Gitee API instead of GitHub. If it is unset, installation defaults to GitHub.
**2. npm package (npmmirror mirror):**
```bash
npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
```
> npmmirror automatically syncs public packages from the public npm registry, so this works directly in China.
**3. Skills only (Gitee mirror):**
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install-skills.sh | sh
```
> With `DWS_GITEE_REPO` set, `install-skills.sh` resolves the version and skills package from Gitee; it also auto-falls back to the Gitee mirror when GitHub is unreachable.
## Upgrade
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
@@ -182,15 +233,56 @@ Credentials are securely persisted after first login (Keychain). Subsequent runs
</details>
<details>
<summary><strong>Multiple organizations (profiles)</strong></summary>
`dws` can stay logged in to several DingTalk organizations at once. Each organization is one **profile**; the current profile decides which org a command runs against (credentials are stored per organization).
```bash
dws auth login # log in to another org → adds a profile (first login becomes the primary)
dws profile list # list logged-in orgs (primary / current marker, status)
dws profile switch <name|corpId> # switch the default org (use - to toggle back to the previous one)
dws --profile <name|corpId> contact user search --query "..." # run one command against a specific org, without changing the default
```
Cross-org reads are orchestrated by the agent rather than a built-in `--all-orgs`: list the profiles, run the query per org with `--profile`, then merge. Writes default to the current org only — confirm the target org before writing across orgs.
</details>
<details>
<summary><strong>Migrate auth between Linux sandboxes</strong></summary>
Copying only `~/.dws/app.json` does not carry the refresh token; access tokens expire after ~2 hours. Use the official export/import flow:
```bash
# Sandbox A (already logged in)
dws auth export -o /tmp/dws-auth.tar.gz
# Or for copy/paste: dws auth export --base64 -o /tmp/dws-auth.b64
# Sandbox B
dws auth import -i /tmp/dws-auth.tar.gz
# Or: dws auth import -i /tmp/dws-auth.b64 --base64
dws auth status # confirm "Refresh Token: valid"
```
The bundle includes the encrypted keychain under `~/.local/share/dws-cli` (with `auth-token.enc` and `dek`) plus required `~/.dws` config files.
</details>
## Quick Start
```bash
dws contact user search --keyword "engineering" # search contacts
dws calendar event list # list calendar events
dws contact user search --query "engineering" # search contacts
dws calendar event list # list today's calendar events
dws doc search --query "quarterly" # search DingTalk Docs
dws minutes list mine # list AI meeting notes I created
dws drive list # list DingTalk drive files
dws todo task create --title "Quarterly report" --executors "<your-userId>" # create a todo (replace <your-userId>)
dws todo task list --dry-run # preview without executing
```
> **Full command list**: [`docs/command-index.md`](./docs/command-index.md) — all commands with descriptions and when-to-use guidance.
## Using with Agents
dws is designed as an AI-native CLI. Complete [Installation](#installation) and [Getting Started](#getting-started) first, then configure your agent:
@@ -202,7 +294,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}'
@@ -219,32 +311,67 @@ dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
# Step 2: Inspect target tool's parameter schema
dws schema aitable.query_records --jq '.tool.parameters'
# Optional: inspect DingTalk authorization metadata for PAT planning
dws schema aitable.query_records --jq '.tool.auth'
# Step 3: Construct the correct call
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
```
### Agent Skills
The repo ships a complete Agent Skill system (`skills/`). After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
The repo ships a complete Agent Skill system under `skills/`, now organized into two layouts:
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 20 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
```bash
# Install skills into current project
# Install skills into current project (defaults to mono)
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
>
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
**What's included:**
**Switching or re-installing with `dws skill setup`:**
```bash
# Interactive: prompts for mode + target agents
dws skill setup
# Install mono skill to every detected agent home (claude / cursor / codex / opencode / qoder)
dws skill setup --mode mono --target all --yes
# Install multi skills to a single agent home
dws skill setup --mode multi --target cursor --yes
# Point at a local source tree (e.g. a fork or work-in-progress)
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
```
| Flag | Values | Description |
|------|--------|-------------|
| `--mode` | `mono` \| `multi` | Skill layout; defaults to interactive prompt |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home |
| `--source` | path | Local source directory (overrides bundled skills) |
| `--yes` | — | Skip confirmation prompts |
Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.ps1`), `DWS_SKILL_SOURCE=<path>`.
**What's included (mono layout):**
| Component | Path | Description |
|-----------|------|-------------|
| Master Skill | `SKILL.md` | Intent routing, decision tree, safety rules, error handling |
| Product references | `references/products/*.md` | Per-product command reference (aitable, chat, calendar, etc.) |
| Intent guide | `references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
| Global reference | `references/global-reference.md` | Auth, output formats, global flags |
| Error codes | `references/error-codes.md` | Error codes + debugging workflows |
| Recovery guide | `references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
| Ready-made scripts | `scripts/*.py` | 13 batch operation scripts (see below) |
| Master Skill | `skills/mono/SKILL.md` | Intent routing, decision tree, safety rules, error handling |
| Product references | `skills/mono/references/products/*.md` | Per-product command reference (aitable, chat, calendar, etc.) |
| Intent guide | `skills/mono/references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
| Global reference | `skills/mono/references/global-reference.md` | Auth, output formats, global flags |
| Error codes | `skills/mono/references/error-codes.md` | Error codes + debugging workflows |
| Recovery guide | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
| Ready-made scripts | `skills/mono/scripts/*.py` | 13 batch operation scripts (see below) |
<details>
<summary><strong>Ready-made scripts</strong> — 13 Python scripts for common multi-step workflows</summary>
@@ -271,6 +398,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>
@@ -281,7 +454,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
@@ -321,6 +494,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
dws schema # list all products and tools
dws schema aitable.query_records # view parameter schema
dws schema aitable.query_records --jq '.tool.required' # view required fields
dws schema aitable.query_records --jq '.tool.auth' # view authorization metadata
dws schema --jq '.products[].id' # extract all product IDs
```
@@ -343,33 +517,60 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
--title "Weekly Report" --text @-
```
> **Note**: `@` is treated as the `@<path>` file-injection prefix only when the next character is an ASCII path-shaped character (`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`), or `@-` for stdin. Chat-bot payloads like `--text "@所有人 周报"` or `--text "@张三 看一下"` pass through unchanged, so literal mentions reach the API as-is.
</details>
## DingTalk bot — connect a robot to your local AI
`dws dev connect` bridges a DingTalk robot to a local AI CLI (Claude Code / Codex / opencode / Qoder / Gemini, or any tool via `--agent-cmd`): @-mention the bot in a chat and it answers using your local agent, keeping per-conversation multi-turn memory.
```bash
dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <secret>
```
In-chat **session commands** (send the bare command as the whole message — no agent turn, no tokens):
| Command | Effect |
|---------|--------|
| `/new` (aliases `/start`, `/reset`) | Start a fresh session; the previous one is left intact (resumable where the agent supports it) |
| `/clear` | Wipe the current session — disposed through the agent's real session op (opencode issues `DELETE /session/:id`); channels whose agent exposes no delete primitive fall back to a reset |
See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step walkthrough (install → create robot → connect → add to a group).
## Key Services
| Service | Command | Commands | Subcommands | Description |
|---------|---------|:--------:|-------------|-------------|
| Contact | `contact` | 6 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
| Chat / IM | `chat` (alias `im`) | 20 | `message` `group` `search` `list-top-conversations` | User-identity send (group / 1-on-1 / open-dingtalk-id), Markdown + image, @mentions; read & search conversations (list, list-all, topic replies, by-sender, mentions, focused, unread, search, info, top / common groups); group CRUD + member management |
| Bot | `chat bot` | 7 | `bot` `group` `message` `search` `create` `search-groups` | Bot create / search, search bot groups; bot-identity group & batch-1:1 messaging, Webhook, message recall; add bot to group |
| Calendar | `calendar` | 13 | `event` `room` `participant` `busy` | Events CRUD, meeting room booking, free-busy query, participant management |
| Todo | `todo` | 6 | `task` | Create, list, update, done, get detail, delete |
| Approval | `oa` | 9 | `approval` | Approve/reject/revoke, pending tasks, initiated instances, process list |
| Attendance | `attendance` | 4 | `record` `shift` `summary` `rules` | Clock-in records, shift schedules, attendance summary, group rules |
| Ding | `ding` | 2 | `message` | Send/recall DING messages |
| Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics |
| AITable | `aitable` | 37 | `base` `table` `record` `field` `attachment` `template` `chart` `dashboard` `export` `import` `view` | Full CRUD for bases/tables/records/fields; charts/dashboards; data import/export; views; templates |
| Doc | `doc` | 16 | `search` `list` `info` `read` `create` `update` `upload` `download` `folder` `block` `comment` | Search, read, create/update documents; block-level editing; file upload/download; comments |
| Minutes | `minutes` | 22 | `list` `get` `update` `record` `hot-word` `mind-graph` `replace-text` `speaker` `upload` | List/search AI meeting transcripts; summaries, transcriptions, todos, mind-maps; recording control; speaker management, hot-words, file upload |
| Workbench | `workbench` | 2 | `app` | Batch query app details |
| DevDoc | `devdoc` | 1 | `article` | Search platform docs and error codes |
| Service | Command | Capabilities |
|---------|---------|--------------|
| Contact | `contact` | Look up users by name / mobile / job-number, departments, labels & roles, roster profiles & dismissals |
| Chat / IM | `chat` (`im`) | Send / reply / search messages, group & member management, bot & webhook messaging, reactions, recall |
| Calendar | `calendar` | Events CRUD, attendees, meeting rooms, free/busy & time suggestions |
| Todo | `todo` | Create / list / update / complete tasks and comments |
| Approval | `oa` | Approve / reject / revoke / transfer; query pending / initiated / CC instances and forms |
| Attendance | `attendance` | Clock-in records, shifts, summaries, group rules (read-only) |
| Ding | `ding` | Send / recall DING messages |
| Report | `report` | Create / submit logs, inbox & outbox, templates, statistics |
| AI Tables | `aitable` | Bases / tables / records / fields / views, permissions & roles, automation, charts & dashboards, import / export |
| Doc | `doc` | Search / read / write docs, block-level editing, comments, permissions, media, up / download |
| Drive | `drive` | List / search / download, folders, upload, copy / move / rename, permissions |
| Minutes | `minutes` | AI meeting notes: list, summary / keywords / transcription / todos, mind map, speakers, tags |
| Mail | `mail` | Mailboxes, KQL search, read / send, drafts, folders, templates, contacts |
| Sheet | `sheet` | Online spreadsheets: worksheet & range read / write, filters, conditional format, images, CSV |
| Wiki | `wiki` | Knowledge bases: spaces, members, node tree, docs & files |
| DevDoc | `devdoc` | Search the Open Platform docs and diagnose API errors |
| AI Search | `aisearch` | Enterprise people search by name / dept / role / duty / supervisor / phone / job-number |
| Live | `live` | List my live streams |
| Raw API | `api` | Call any DingTalk OpenAPI directly, with managed app-level token |
> 152 commands across 14 products. Run `dws --help` for the full list, or `dws <service> --help` for subcommands.
> Full command listing with usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for any service's 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) · `drive` (cloud drive) · `conference` (video) · `tb` (Teambition) · `aiapp` (AI apps) · `live` (streaming) · `skill` (marketplace)
- `conference` (video meetings)
- Multi-skill mode (experimental) — per-product skills under `skills/multi/`; opt in via `dws skill setup --mode multi`
</details>
@@ -418,8 +619,10 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
## Reference & Docs
- [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
- [Open Platform App Command Routing](./docs/dev-yulan-command-routing.md) — yulan dev app command design, MCP overlay, permission flow, and Agent routing
- [Changelog](./CHANGELOG.md) — release history and migration notes
## Contributing
+232 -31
View File
@@ -21,7 +21,7 @@
> [!IMPORTANT]
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
>
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
<details>
<summary><strong>目录</strong></summary>
@@ -63,6 +63,27 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
```
<details>
<summary><strong>Skill 模式:mono 与 multi</strong></summary>
安装时可以选择两种 skill 组织方式。两种模式下 CLI 命令完全一样(`dws aitable ...` / `dws calendar ...`),区别只在 Agent 那边读到的 skill 文档结构。
| 模式 | 安装内容 | 适合场景 |
|------|----------|----------|
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
| **multi** 🧪 **试验版 / Preview** | 20 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。20 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
怎么选:
- **快速安装**(上方一行 curl):非交互,默认装 `mono`。
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) mono 2) multi` 选项(默认 1)。
- **环境变量覆盖**:`DWS_SKILL_MODE=multi curl -fsSL ... | sh`。
- **装完之后再切换**:`dws skill setup --mode multi`(或 `--mode mono`),随时重跑都行。
</details>
<details>
<summary>其他安装方式</summary>
@@ -92,6 +113,36 @@ cp dws ~/.local/bin/ # 安装到 PATH
</details>
## 国内加速安装
国内用户可使用以下通道,避免 GitHub 网络问题。默认(不设置这些环境变量)走 GitHub。
**1. 安装脚本 + 预编译二进制(Gitee 镜像):**
仓库镜像地址:`https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli`
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
```
> 设置 `DWS_GITEE_REPO` 后,安装脚本会改从 Gitee API 解析最新版本和各个 release 产物(二进制、校验和、skills 包),而不是走 GitHub。不设置时默认从 GitHub 安装。
**2. npm 包(npmmirror 镜像):**
```bash
npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
```
> npmmirror 会自动同步公网 npm 的公开包,国内可直接使用。
**3. 单独安装 Skills(Gitee 镜像):**
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install-skills.sh | sh
```
> 同样设置 `DWS_GITEE_REPO`,`install-skills.sh` 会从 Gitee 解析版本和 skills 包;GitHub 不可达时也会自动回退到 Gitee 镜像。
## 升级
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
@@ -182,15 +233,56 @@ dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
</details>
<details>
<summary><strong>多组织(profile)</strong></summary>
`dws` 可以同时登录多个钉钉组织。一个组织就是一个 **profile**,当前 profile 决定本次命令操作哪个组织(凭证按组织分别存储)。
```bash
dws auth login # 再登录一个组织 → 新增一个 profile(首次登录的为主组织)
dws profile list # 列出已登录组织(主 / 当前标记、状态)
dws profile switch <名称|corpId> # 切换默认组织(用 - 切回上一个)
dws --profile <名称|corpId> contact user search --query "..." # 单次对指定组织执行,不改默认组织
```
跨组织读取由 agent 编排,而非内置 `--all-orgs`:先 `dws profile list` 拿到组织,再对每个组织带 `--profile` 各查一遍,然后合并。写操作默认只在当前组织进行——跨组织写之前先确认目标组织。
</details>
<details>
<summary><strong>沙箱间迁移登录态(Linux)</strong></summary>
仅拷贝 `~/.dws/app.json` 无法带走 refresh token;access token 约 2 小时后会失效。请使用官方导出/导入:
```bash
# A 沙箱(已登录)
dws auth export -o /tmp/dws-auth.tar.gz
# 或便于分片复制:dws auth export --base64 -o /tmp/dws-auth.b64
# B 沙箱
dws auth import -i /tmp/dws-auth.tar.gz
# 或:dws auth import -i /tmp/dws-auth.b64 --base64
dws auth status # 确认 Refresh Token: 有效
```
包内包含 `~/.local/share/dws-cli` 加密 keychain(含 `auth-token.enc` 与 `dek`)及 `~/.dws` 必要配置。
</details>
## 快速开始
```bash
dws contact user search --keyword "悟空" # 搜索联系人
dws calendar event list # 查看日历日程
dws contact user search --query "悟空" # 搜索联系人
dws calendar event list # 查看今天的日程
dws doc search --query "季度" # 搜索钉钉文档
dws minutes list mine # 列出我创建的 AI 听记
dws drive list # 列出钉盘文件
dws todo task create --title "季度汇报" --executors "<your-userId>" # 创建待办(请替换为真实 userId)
dws todo task list --dry-run # 预览操作但不执行
```
> **完整命令列表**:[`docs/command-index.md`](./docs/command-index.md) — 全部命令,带描述和使用场景。
## 在 Agent 中使用
dws 是为 AI Agent 设计的 CLI 工具。请先完成[安装](#安装)和[开始使用](#开始使用),然后配置 Agent 环境:
@@ -202,7 +294,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}'
@@ -225,26 +317,58 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
### Agent Skills
仓库内置完整的 Agent Skill 体系(`skills/`),安装后 Claude Code / Cursor 等 AI 工具可通过自然语言直接操作钉钉:
仓库内置完整的 Agent Skill 体系(`skills/` 目录),目前重组为两套布局:
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 18 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
```bash
# 安装 skills 到当前项目
# 安装 skills 到当前项目(默认 mono)
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
>
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
**包含内容:**
**用 `dws skill setup` 切换或重装:**
```bash
# 交互式:提示选模式 + 目标 Agent
dws skill setup
# 把 mono skill 铺到所有检测到的 Agent home(claude / cursor / codex / opencode / qoder)
dws skill setup --mode mono --target all --yes
# 只装到某一个 Agent home
dws skill setup --mode multi --target cursor --yes
# 指定本地源目录(比如 fork 或正在改的版本)
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
```
| 参数 | 取值 | 说明 |
|------|------|------|
| `--mode` | `mono` \| `multi` | skill 布局,不指定则交互式询问 |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | 安装目标,`all` 表示铺到所有检测到的 Agent home |
| `--source` | 路径 | 本地源目录(覆盖内置 skills) |
| `--yes` | — | 跳过确认提示 |
环境变量:`DWS_SKILL_MODE=mono|multi`(`install.sh` / `install.ps1` 也认)、`DWS_SKILL_SOURCE=<路径>`。
**包含内容(mono 布局):**
| 组件 | 路径 | 说明 |
|------|------|------|
| 主 Skill | `SKILL.md` | 意图路由、决策树、安全规则、错误处理 |
| 产品参考 | `references/products/*.md` | 各产品命令详细参考(aitable、chat、calendar 等) |
| 意图指南 | `references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
| 全局参考 | `references/global-reference.md` | 认证、输出格式、全局 flag |
| 错误码 | `references/error-codes.md` | 错误码 + 调试流程 |
| Recovery 指南 | `references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
| 现成脚本 | `scripts/*.py` | 13 个批量操作脚本(见下方) |
| 主 Skill | `skills/mono/SKILL.md` | 意图路由、决策树、安全规则、错误处理 |
| 产品参考 | `skills/mono/references/products/*.md` | 各产品命令详细参考(aitable、chat、calendar 等) |
| 意图指南 | `skills/mono/references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
| 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag |
| 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 |
| Recovery 指南 | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
| 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
<details>
<summary><strong>现成脚本</strong> — 13 个 Python 脚本,覆盖常见多步工作流</summary>
@@ -271,6 +395,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>
@@ -281,7 +451,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
@@ -343,31 +513,60 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
--title "周报" --text @-
```
> **说明**:`@` 仅在其后是 ASCII 路径前缀字符(`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`)或 `@-`(stdin)时,才会被识别为 `@<path>` 文件注入语法。`--text "@所有人 周报"` / `--text "@张三 看一下"` 这类机器人消息中的字面 `@` 提及会原样透传到 API。
</details>
## 钉钉机器人 —— 把机器人接到你本地的 AI
`dws dev connect` 把一个钉钉机器人接到本地 AI CLI(Claude Code / Codex / opencode / Qoder / Gemini,或用 `--agent-cmd` 接任意工具):群里 @ 机器人提问,它用你本地的 agent 回答,按会话保留多轮上下文。
```bash
dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <secret>
```
聊天里的**会话指令**(整条消息就是指令时生效,不消耗一次 AI 调用):
| 指令 | 作用 |
|------|------|
| `/new`(别名 `/start`、`/reset`) | 开启新会话;旧会话保留(agent 支持的话仍可回溯) |
| `/clear` | 清空当前会话 —— 调 agent 真实会话原语真删(opencode 走 `DELETE /session/:id`);驱动接口没有删除原语的渠道退化为重置 |
完整四步教程见 [`docs/robot-quickstart.md`](./docs/robot-quickstart.md)(装工具 → 建机器人 → 接上 AI → 拉进群)。
## 核心服务
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|------|------|:------:|--------|------|
| 通讯录 | `contact` | 6 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
| 群聊 | `chat` | 10 | `message` `group` `search` | 群增删改查、成员管理、机器人消息、Webhook |
| 机器人 | `chat bot` | 6 | `bot` `group` `message` `search` | 机器人创建/搜索、群聊/单聊消息、Webhook、消息撤回 |
| 日历 | `calendar` | 13 | `event` `room` `participant` `busy` | 日程增删改查、会议室预订、闲忙查询、参与者管理 |
| 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 |
| 审批 | `oa` | 9 | `approval` | 同意/拒绝/撤销、待我审批、我发起的、流程列表 |
| 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 |
| DING | `ding` | 2 | `message` | 发送/撤回 DING 消息 |
| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、统计 |
| 智能表格 | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | 多维表/数据表/记录/字段全量 CRUD、模板 |
| 工作台 | `workbench` | 2 | `app` | 批量查询应用详情 |
| 开发者文档 | `devdoc` | 1 | `article` | 搜索开放平台文档与错误码 |
| 服务 | 命令 | 能力 |
|------|------|------|
| 通讯录 | `contact` | 按姓名 / 手机号 / 工号查人,部门、角色标签、花名册与离职 |
| 群聊 | `chat`(`im`)| 发送 / 回复 / 搜索消息,群与成员管理,机器人与 Webhook 发消息,表情反应,撤回 |
| 日历 | `calendar` | 日程 CRUD、参与者、会议室、闲忙与时间建议 |
| 待办 | `todo` | 创建 / 列表 / 修改 / 完成待办及评论 |
| 审批 | `oa` | 同意 / 拒绝 / 撤销 / 转交,查待办 / 已发起 / 抄送及表单 |
| 考勤 | `attendance` | 打卡记录、排班、考勤摘要、考勤组规则(只读) |
| DING | `ding` | 发送 / 撤回 DING 消息 |
| 日志 | `report` | 创建 / 提交日志,收发件箱,模版,统计 |
| AI 表格 | `aitable` | Base / 数据表 / 记录 / 字段 / 视图,权限与角色,自动化,图表与仪表盘,导入导出 |
| 文档 | `doc` | 搜索 / 读写文档,块级编辑,评论,权限,媒体,上传 / 下载 |
| 钉盘 | `drive` | 列表 / 搜索 / 下载,文件夹,上传,复制 / 移动 / 重命名,权限 |
| AI 听记 | `minutes` | 听记列表、摘要 / 关键词 / 转写 / 待办、思维导图、发言人、标签 |
| 邮箱 | `mail` | 邮箱、KQL 搜索、读 / 发、草稿、文件夹、模版、联系人 |
| 在线电子表格 | `sheet` | 在线表格:工作表与区域读写、筛选、条件格式、图片、CSV |
| 知识库 | `wiki` | 知识库:空间、成员、节点树、文档与文件 |
| 开发者文档 | `devdoc` | 搜索开放平台文档并排查 API 错误 |
| AI 搜问 | `aisearch` | 企业人员搜索:按姓名 / 部门 / 角色 / 职责 / 上下级 / 手机号 / 工号 |
| 直播 | `live` | 查看我的直播列表 |
| Raw API | `api` | 直接调用任意钉钉 OpenAPI,自动管理应用级 Token |
> 12 个产品,86 个命令。运行 `dws --help` 查看完整列表,或 `dws <service> --help` 查看子命令。
> 完整命令清单(带描述与使用场景):[`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>
`doc`(文档)· `mail`(邮箱)· `minutes`(AI 听记)· `drive`(钉盘)· `conference`(视频会议)· `tb`(Teambition)· `aiapp`(AI 应用)· `live`(直播)· `skill`(技能市场)
- `conference`(视频会议)
- 多 skill 模式(实验中)— 每产品一个独立 skill,位于 `skills/multi/`,通过 `dws skill setup --mode multi` 启用
</details>
@@ -418,8 +617,10 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
## 参考与文档
- [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
- [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层
- [开放平台应用指令设计](./docs/dev-yulan-command-routing.md) — yulan dev app 应用侧命令、MCP overlay、权限流程与 Agent 路由
- [更新日志](./CHANGELOG.md) — 版本历史与迁移说明
## 贡献指南
+3
View File
@@ -43,6 +43,8 @@ __KEG_ONLY_LINE__
Pathname.new(File.join(Dir.home, ".agents/skills/dws")),
Pathname.new(File.join(Dir.home, ".claude/skills/dws")),
Pathname.new(File.join(Dir.home, ".cursor/skills/dws")),
Pathname.new(File.join(Dir.home, ".qoder/skills/dws")),
Pathname.new(File.join(Dir.home, ".qoderwork/skills/dws")),
Pathname.new(File.join(Dir.home, ".gemini/skills/dws")),
Pathname.new(File.join(Dir.home, ".codex/skills/dws")),
Pathname.new(File.join(Dir.home, ".github/skills/dws")),
@@ -53,6 +55,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|
+39 -3
View File
@@ -12,6 +12,8 @@ const AGENT_DIRS = [
".agents/skills",
".claude/skills",
".cursor/skills",
".qoder/skills",
".qoderwork/skills",
".gemini/skills",
".codex/skills",
".github/skills",
@@ -22,6 +24,7 @@ const AGENT_DIRS = [
".kiro/skills",
".trae/skills",
".openclaw/skills",
".hermes/skills",
];
const PLATFORM_MAP = {
@@ -135,11 +138,36 @@ function installSkillsToHomes(skillRoot) {
}
}
// cacheUserSkills copies the mono and multi trees out of the freshly extracted
// dws-skills.zip into ~/.dws/skills/{mono,multi}/ so that `dws skill setup`
// can fall back to a user-local cache when --source is not provided. mono is
// already installed into agent homes by installSkillsToHomes; the cache is
// purely a source-of-truth for the setup command.
function cacheUserSkills(extractedSkillsRoot) {
const cacheBase = path.join(os.homedir(), ".dws", "skills");
const monoSource = fs.existsSync(path.join(extractedSkillsRoot, "mono", "SKILL.md"))
? path.join(extractedSkillsRoot, "mono")
: extractedSkillsRoot;
const monoCache = path.join(cacheBase, "mono");
fs.rmSync(monoCache, { recursive: true, force: true });
copyChildren(monoSource, monoCache);
const multiSource = path.join(extractedSkillsRoot, "multi");
if (fs.existsSync(multiSource) && fs.statSync(multiSource).isDirectory()) {
const multiCache = path.join(cacheBase, "multi");
fs.rmSync(multiCache, { recursive: true, force: true });
copyChildren(multiSource, multiCache);
}
}
function main() {
const packageRoot = __dirname;
const assetsDir = path.join(packageRoot, "assets");
const vendorDir = path.join(packageRoot, "vendor");
const skillDir = path.join(packageRoot, "share", "skills", "dws");
// Extract dws-skills.zip into a staging directory so we can split mono/
// (installed to agent homes) from multi/ (cached for later setup use).
const skillsStaging = path.join(packageRoot, "share", "skills");
const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`];
if (!assetName) {
throw new Error(`unsupported platform: ${process.platform}/${process.arch}`);
@@ -155,8 +183,16 @@ function main() {
}
extractArchive(archivePath, vendorDir);
extractSkills(skillsPath, skillDir);
installSkillsToHomes(skillDir);
extractSkills(skillsPath, skillsStaging);
// For backward compatibility, the zip root carries a copy of mono content
// (SKILL.md + references/ + scripts/). Prefer the explicit mono/ subdir
// when present; fall back to the staging root otherwise.
const monoRoot = fs.existsSync(path.join(skillsStaging, "mono", "SKILL.md"))
? path.join(skillsStaging, "mono")
: skillsStaging;
installSkillsToHomes(monoRoot);
cacheUserSkills(skillsStaging);
}
main();
+93
View File
@@ -0,0 +1,93 @@
# Agent identification (agent_code & agentId)
dws tags every MCP request with **which agent host is driving it** and a
**per-instance id**, so usage can be sliced by channel/instance in the data
warehouse. This page is the integration contract.
## What dws sends on the wire
| Header | Meaning | Granularity |
|--------|---------|-------------|
| `x-dingtalk-dws-agent-code` | which agent host (claudecode / codex / qoder / cursor / custom if explicitly declared …) | channel |
| `x-dws-agent-instance-id` | `dwsa_<base62>` derived from `machineId + agent_code` | machine × channel |
| `x-dws-agent-id` | stable per-install machine id (v1-compatible) | machine |
| `X-Cli-Version` | dws CLI version (segments old vs new clients) | — |
`x-dws-agent-id` keeps its original machine-level meaning for backward
compatibility; `x-dws-agent-instance-id` is the new per-channel value. Old
clients send no `agent_code` / instance id — treat their absence as
"legacy/unknown", not an error.
## How `agent_code` is resolved (confidence ladder)
1. **T0 — explicit declaration:** `DINGTALK_DWS_AGENTCODE=<code>`. **Use this.**
2. **T1 — verified env signature:** an agent that auto-sets a distinctive var
(`CLAUDECODE`, `CODEX_SANDBOX`, `OPENCLAW_BUNDLE_ROOT`, `HERMES_HOME`).
3. **T2 — `VSCODE_BRAND`:** every VS Code fork declares its brand — one rule
covers Cursor / Windsurf / Trae / Qoder / Kiro / … incl. future forks.
4. **T3 — macOS `__CFBundleIdentifier`:** known agent app bundles.
5. **T4 — unresolved:** unknown host sends no agent_code. Never guessed.
## Declaring your agent (recommended — the only fully-general path)
Auto-detection cannot cover every agent: most terminal agents (gemini/
antigravity, aider, opencode, qwen-code, crush, goose, kimi, amazon-q,
continue, …) expose **no reliable self-identifying env var** — only user-set
API keys, which must not be used as identity. The robust answer is: **the host
sets `DINGTALK_DWS_AGENTCODE` in the env block where it launches dws as an MCP
server.** This is accurate for any agent, on any OS, and is future-proof.
MCP server config example (JSON-style hosts):
```jsonc
{
"mcpServers": {
"dingtalk-workspace": {
"command": "dws",
"args": ["mcp", "..."],
"env": { "DINGTALK_DWS_AGENTCODE": "your-agent-code" }
}
}
}
```
### Canonical codes
`claudecode`, `codex`, `cursor`, `vscode`, `qoder`, `windsurf`, `trae`,
`workbuddy`, `openclaw`, `hermes`, `codebuddy`, `comate`, `lingma`, `gemini`,
`aider`, `opencode`, `goose`, `crush`, `kimi`, `amazonq`, `continue`, …
Use a stable slug. Values declared via `DINGTALK_DWS_AGENTCODE` are forwarded
verbatim so PAT grants and follow-up command checks use the same key.
## Trust & limitations — READ THIS
**`agent_code` AND the ids (`x-dws-agent-id`, `x-dws-agent-instance-id`) are
self-reported, best-effort signals, NOT an authenticated identity.**
- `agent_code`: every declaration/auto-detect signal is an env var the
host/user controls — spoofable (`export CLAUDECODE=1` → dws reports
`claudecode`).
- The ids are **even easier to forge**: they are generated, stored, and sent
entirely client-side. `machineId` is a random UUID in the plaintext
`~/.dws/identity.json` (which the user owns), and the instance id is just
`sha256(machineId + agent_code)`. Editing that one file — or rewriting the
header — lets anyone mint, split, rotate, or impersonate ids at will. The
`dwsa_` prefix does NOT make it a secure identifier.
- ✅ **Fit for statistics / observability** (the intended use): there is no
incentive to misreport one's own agent, and real hosts emit real signals, so
aggregate per-channel metrics are reliable in practice.
- ❌ **NOT fit for authentication, authorization, rate-limiting, billing, or
revocation.** Anything where a party benefits from lying must not trust this
field. For control-plane use you need a gateway-issued **authoritative**
agentId bound to a verified credential (clientId / PAT / OAuth) — a separate,
heavier mechanism, deliberately out of scope here.
Treat `agent_code` / `x-dws-agent-instance-id` as analytics dimensions only.
## Gateway side (required for the data to land)
dws sending the headers is necessary but not sufficient. The gateway must:
1. add `x-dingtalk-dws-agent-code`, `x-dws-agent-instance-id`, `X-Cli-Version`
to the upstream-header pass-through allowlist (otherwise they are stripped);
2. log them as fields, and deliver them to the warehouse (alongside the
existing flow-control / execution logs).
+323
View File
@@ -0,0 +1,323 @@
# dws Command Index
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
- **Source**: `dws-wukong/envelope/channel/open/pre/config.json`
- **Products**: 13
- **Total commands**: 160
- **Generated from**: `internal/compat.BuildDynamicCommands` rendering of the pre config — the same code path the CLI uses at runtime.
> Auto-generated. Edit `pre/config.json`, not this file.
## Global flags
Every command inherits these flags (documented here once, not repeated per command):
| Flag | Purpose |
|---|---|
| `--client-id` | Override OAuth client ID (DingTalk AppKey) |
| `--client-secret` | Override OAuth client secret (DingTalk AppSecret) |
| `--debug` | Enable debug logging |
| `--dry-run` | Preview the request without executing |
| `--fields` | Comma-separated output field projection |
| `-f, --format` | Output format: `json` \| `table` \| `raw` (default `json`) |
| `--jq` | jq expression applied to JSON output |
| `--mock` | Return mock data (developer aid) |
| `-o, --output` | Write output to a file |
| `--timeout` | HTTP request timeout in seconds (default 30) |
| `--token` | Override the configured API token |
| `-v, --verbose` | Verbose logging |
| `-y, --yes` | Skip confirmation prompts (AI-agent mode) |
## Contents
- [`dws aitable` — AI Tables](#dws-aitable) · 41 commands
- [`dws attendance` — Attendance](#dws-attendance) · 4 commands
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
- [`dws doc` — DingTalk Doc](#dws-doc) · 21 commands
- [`dws drive` — DingTalk Drive](#dws-drive) · 6 commands
- [`dws minutes` — AI Minutes](#dws-minutes) · 19 commands
- [`dws oa` — OA Approval](#dws-oa) · 9 commands
- [`dws report` — Reports](#dws-report) · 7 commands
- [`dws todo` — Todo Tasks](#dws-todo) · 6 commands
## `dws aitable` — AI Tables
_AI-powered spreadsheet (Base) with datasheets, fields, records, views, dashboards, charts, import/export, attachments, and templates._
**41 commands**
| Command | Description | When to use |
|---|---|---|
| `dws aitable attachment upload` | Request an upload ticket for attaching a file to an AI table attachment-type field. Returns an upload URL and token the caller uses to stream the file. | When the agent needs to attach binary assets (images, PDFs, etc.) to records before creating or updating an attachment field value. |
| `dws aitable base create` | Create a new AI table (Base) under the current user's workspace. Returns the newly-created Base ID. | When an agent needs to provision a fresh Base before populating datasheets, fields, and records. |
| `dws aitable base delete` | Permanently delete an existing AI table (Base) by ID, removing all its datasheets, views, and records. | When the agent is cleaning up a Base that is no longer needed or was created for a one-off task. |
| `dws aitable base get` | Retrieve metadata for a single AI table (Base), including name, owner, and structural summary. | When the agent needs to inspect a specific Base before performing further operations on it. |
| `dws aitable base list` | List AI tables (Bases) accessible to the current user, paginated. | When the agent needs to enumerate the user's Bases to pick one by name or index. |
| `dws aitable base search` | Search AI tables (Bases) the current user can access by keyword against the Base name. | When the agent knows a partial Base name and needs to resolve it to a Base ID. |
| `dws aitable base update` | Update mutable properties of an AI table (Base), such as its name or icon. | When the agent needs to rename or rebrand an existing Base without touching its data. |
| `dws aitable chart create` | Create a new chart inside a Base, bound to a datasheet and view with a given configuration. | When the agent is building analytics on top of a datasheet and needs to materialize a chart visualization. |
| `dws aitable chart delete` | Delete a chart from a Base by chart ID. | When the agent needs to remove an obsolete or mistakenly-created chart. |
| `dws aitable chart get` | Retrieve a chart's full configuration and metadata. | When the agent needs to inspect an existing chart to clone it or adjust its configuration. |
| `dws aitable chart share get` | Retrieve the current public-sharing configuration of a chart, including share link and permissions. | When the agent needs to check whether a chart is already shared externally before issuing a link. |
| `dws aitable chart share update` | Enable, disable, or update the public-sharing configuration of a chart. | When the agent needs to generate or revoke an external share link for a chart. |
| `dws aitable chart update` | Update an existing chart's configuration (type, dimensions, metrics, style). | When the agent iterates on a chart's visualization after reviewing the initial result. |
| `dws aitable chart widgets-example` | Return a reference JSON example of chart widget configuration accepted by chart create/update. | When the agent needs a schema template before composing chart configuration payloads. |
| `dws aitable dashboard config-example` | Return a reference JSON example of dashboard configuration accepted by dashboard create/update. | When the agent needs a schema template before composing dashboard layout payloads. |
| `dws aitable dashboard create` | Create a new dashboard inside a Base with a layout of chart widgets. | When the agent wants to group multiple charts into a single dashboard view for a report or overview page. |
| `dws aitable dashboard delete` | Delete a dashboard from a Base by dashboard ID. | When the agent is removing an outdated dashboard. |
| `dws aitable dashboard get` | Retrieve a dashboard's layout, widget list, and metadata. | When the agent needs to inspect a dashboard before updating it or cloning it. |
| `dws aitable dashboard share get` | Retrieve the current public-sharing configuration of a dashboard. | When the agent needs to verify whether a dashboard has an active external share link. |
| `dws aitable dashboard share update` | Enable, disable, or update the public-sharing configuration of a dashboard. | When the agent needs to generate or revoke an external share link for a dashboard. |
| `dws aitable dashboard update` | Update an existing dashboard's layout, widgets, or metadata. | When the agent adds, removes, or rearranges charts on an existing dashboard. |
| `dws aitable export data` | Export data from a datasheet (optionally scoped to a view) to a downloadable file such as Excel or CSV. | When the agent needs to hand off Base data to an external system or deliver it as an attachment. |
| `dws aitable field create` | Create one or more fields in a datasheet with specified types and options. | When the agent is extending a datasheet's schema to capture new attributes. |
| `dws aitable field delete` | Delete a field from a datasheet by field ID; all values in that column are removed. | When the agent is cleaning up unused or deprecated columns in a datasheet. |
| `dws aitable field get` | Retrieve field definitions for a datasheet, including type, options, and order. | When the agent needs the field schema before constructing record payloads or queries. |
| `dws aitable field update` | Update a field's name, type, or options in a datasheet. | When the agent needs to rename a column or change its type/options without recreating it. |
| `dws aitable import data` | Import previously-uploaded data (e.g. Excel) into a datasheet as records, optionally creating fields. | When the agent is bulk-loading external data into a Base after a successful import upload. |
| `dws aitable import upload` | Request an upload ticket for an import file (Excel/CSV) to be staged before calling import data. | When the agent needs to push a local dataset into a Base and must first stage the file. |
| `dws aitable record create` | Insert one or more records into a datasheet with given field values. | When the agent needs to add new rows to a datasheet, individually or in batches. |
| `dws aitable record delete` | Delete one or more records from a datasheet by record ID. | When the agent removes rows that are obsolete or were created in error. |
| `dws aitable record query` | Query records from a datasheet with optional filters, sort, view scoping, and pagination. | When the agent needs to read row data to reason about it, render it, or feed it into downstream logic. |
| `dws aitable record update` | Update field values on one or more existing records by record ID. | When the agent modifies specific row values after reading or computing new data. |
| `dws aitable table create` | Create a new datasheet (table) inside a Base. | When the agent needs another table alongside existing ones in the same Base. |
| `dws aitable table delete` | Delete a datasheet from a Base by table ID, removing all its records, views, and fields. | When the agent is disposing of a datasheet that is no longer needed. |
| `dws aitable table get` | List datasheets within a Base, returning table IDs and names. | When the agent needs to resolve a table name to an ID inside a known Base. |
| `dws aitable table update` | Update a datasheet's name or other metadata. | When the agent needs to rename a datasheet without altering its contents. |
| `dws aitable template search` | Search the AI table template gallery by keyword. | When the agent needs to suggest or bootstrap from an existing Base template rather than building from scratch. |
| `dws aitable view create` | Create a new view (grid, gallery, kanban, etc.) on a datasheet. | When the agent needs an alternate filtered/sorted presentation of the same datasheet data. |
| `dws aitable view delete` | Delete a view from a datasheet by view ID. | When the agent is cleaning up unused views. |
| `dws aitable view get` | Retrieve view definitions for a datasheet, including filter, sort, and visible-field configuration. | When the agent needs to understand or reuse a view's configuration before querying records through it. |
| `dws aitable view update` | Update a view's name, filter, sort, grouping, or visible fields. | When the agent refines an existing view's configuration after inspection. |
## `dws attendance` — Attendance
_Attendance check-in records, shifts, and aggregate statistics._
**4 commands**
| Command | Description | When to use |
|---|---|---|
| `dws attendance record get` | Query a user's detailed clock-in/clock-out attendance records for a given time range. | When the agent needs to verify punctuality, pull attendance evidence, or build an attendance report for an individual. |
| `dws attendance rules` | Query the attendance group the user belongs to along with its attendance rules (schedule, locations, shifts). | When the agent needs to know the user's expected work schedule or attendance policies before interpreting records. |
| `dws attendance shift list` | Batch-query the assigned shifts for a set of employees over a date range. | When the agent needs to plan around team shifts or compile a shift-based roster. |
| `dws attendance summary` | Retrieve an aggregated attendance summary for a single user (totals of late, early-leave, absence, overtime). | When the agent needs a quick attendance health check without pulling raw records. |
## `dws calendar` — Calendar
_Calendar events, participants, meeting rooms, and busy-status queries._
**14 commands**
| Command | Description | When to use |
|---|---|---|
| `dws calendar busy search` | Query the busy/free time windows of one or more users over a given range. | When the agent is scheduling a meeting and needs to find a slot where all attendees are free. |
| `dws calendar event create` | Create a new calendar event on the user's calendar with title, time, attendees, and optional meeting room. | When the agent schedules a meeting or reminder on behalf of the user. |
| `dws calendar event delete` | Delete an existing calendar event by event ID. | When the agent cancels a previously scheduled event. |
| `dws calendar event get` | Retrieve the full details of a calendar event, including participants, location, and body. | When the agent needs to inspect an event before updating or referencing it. |
| `dws calendar event list` | List calendar events on the user's calendar within a given time range. | When the agent needs an overview of the user's upcoming schedule or a day's agenda. |
| `dws calendar event suggest` | Suggest candidate meeting time slots based on participants' busy/free data and constraints. | When the agent is coordinating a meeting and wants ranked time suggestions rather than raw busy data. |
| `dws calendar event update` | Update an existing calendar event's fields such as time, title, participants, or location. | When the agent needs to reschedule or amend a previously created event. |
| `dws calendar participant add` | Add one or more participants to an existing calendar event. | When the agent invites additional attendees after the event has been created. |
| `dws calendar participant delete` | Remove one or more participants from an existing calendar event. | When the agent drops attendees who no longer need to join the event. |
| `dws calendar participant list` | List current participants of a calendar event along with their response status. | When the agent needs to check who is attending before sending follow-up reminders. |
| `dws calendar room add` | Book a specific meeting room onto an existing calendar event. | When the agent needs to attach a physical meeting room to an already-scheduled event. |
| `dws calendar room delete` | Release a previously booked meeting room from a calendar event. | When the agent cancels or changes the room on an existing event. |
| `dws calendar room list-groups` | List meeting room groups (usually by building or floor) available to the user. | When the agent is narrowing down rooms by location before running an availability search. |
| `dws calendar room search` | Search meeting rooms by keyword within a group, optionally filtering to rooms free during a given window via `--available`. | When the agent needs to find a suitable room, typically free at a specific time, prior to booking. |
## `dws chat` — Group Chat / IM
_Group chats, conversations, messages, and robot/webhook integrations._
**23 commands**
| Command | Description | When to use |
|---|---|---|
| `dws chat bot search` | Search robots (bots) created by the current user by keyword. | When the agent needs to resolve one of its own bots by name to a robot code before sending bot messages. |
| `dws chat conversation-info` | Retrieve basic metadata for a conversation (single chat or group chat) by conversation ID. | When the agent needs context about a conversation (name, type, member count) before operating on it. |
| `dws chat group create` | Create a new internal group chat with a set of initial members. | When the agent needs to spin up a dedicated group for a new project, incident, or discussion thread. |
| `dws chat group members` | List members of a group chat; can also be used against the current user to enumerate their groups' members. | When the agent needs the roster of a group before mentioning, removing, or auditing members. |
| `dws chat group members add` | Add one or more users to an existing group chat. | When the agent expands a group to include additional participants. |
| `dws chat group members add-bot` | Add a robot (bot) to an existing group chat so the bot can post messages there. | When the agent needs to enable bot-driven notifications in a group that does not yet contain the bot. |
| `dws chat group members remove` | Remove one or more members from a group chat. | When the agent kicks users who should no longer have access to the group. |
| `dws chat group rename` | Update the display name of a group chat. | When the agent is rebranding or clarifying the purpose of an existing group. |
| `dws chat list-top-conversations` | Fetch the list of conversations the current user has pinned to the top of their chat list. | When the agent needs to prioritize the user's most important conversations in a summary or dashboard. |
| `dws chat message list` | Pull the recent message history of a specific conversation (v2), paginated. | When the agent needs to read what has recently been said in a conversation to summarize or reason about it. |
| `dws chat message list-all` | Search all messages across the current user's conversations within a time range. | When the agent needs to audit or summarize everything the user saw across chats in a window. |
| `dws chat message list-by-sender` | Fetch messages authored by a specific sender across both single and group chats. | When the agent needs to pull everything a particular colleague said recently. |
| `dws chat message list-focused` | Fetch messages from users the current user has marked as "special focus" (starred contacts). | When the agent builds a priority-inbox view highlighting messages from important people. |
| `dws chat message list-mentions` | Fetch messages where the current user was @-mentioned. | When the agent wants to surface items that explicitly require the user's attention. |
| `dws chat message list-topic-replies` | Pull replies under a specific group topic thread. | When the agent needs the conversation tree of a threaded discussion rather than the flat message list. |
| `dws chat message list-unread-conversations` | Fetch the list of conversations that currently have unread messages for the user. | When the agent builds a "catch me up" triage view of what still needs reading. |
| `dws chat message recall-by-bot` | Recall (retract) a message previously sent by a robot in a group chat. | When the agent sent a bot message in error or with incorrect content and needs to withdraw it. |
| `dws chat message search` | Search messages by keyword across the user's conversations. | When the agent needs to locate a specific statement or link the user remembers from chat history. |
| `dws chat message send` | Send a message into a group chat or single chat as the authenticated user. | When the agent needs to relay a response to a user or notify a group on behalf of the human operator. |
| `dws chat message send-by-bot` | Send a group message as a specific robot (bot) the user owns. | When the agent posts automated notifications under a bot identity rather than as the user. |
| `dws chat message send-by-webhook` | Send a group message via a custom-robot incoming webhook URL. | When the agent needs to post to a group using a webhook without requiring full bot-permission setup. |
| `dws chat search` | Search group conversations the user belongs to by group name keyword. | When the agent needs to resolve a group name to a conversation ID. |
| `dws chat search-common` | Find group chats the current user and a specified other user both belong to. | When the agent needs an existing shared channel to contact another user without creating a new group. |
## `dws contact` — Contact Directory
_Users, departments, and directory lookups._
**6 commands**
| Command | Description | When to use |
|---|---|---|
| `dws contact dept list-members` | List members of a specific department by department ID. | When the agent needs the roster of a department to target communication or build a team overview. |
| `dws contact dept search` | Search departments in the organization's contact directory by keyword. | When the agent needs to resolve a department name to a department ID. |
| `dws contact user get` | Batch-fetch detailed profile information for one or more users by user ID. | When the agent needs names, titles, emails, or departments for a known set of user IDs. |
| `dws contact user get-self` | Retrieve the profile of the currently authenticated user. | When the agent needs to identify who it is acting on behalf of (user ID, name, org). |
| `dws contact user search` | Search users in the contact directory by keyword (name, title, etc.). | When the agent needs to resolve a person's display name to a user ID. |
| `dws contact user search-mobile` | Look up a user by mobile phone number. | When the agent has only a phone number and needs to find the corresponding DingTalk user. |
## `dws devdoc` — Open Platform Docs
_Search the DingTalk Open Platform documentation._
**2 commands**
| Command | Description | When to use |
|---|---|---|
| `dws devdoc article search` | Search the DingTalk Open Platform documentation by keyword. | When the agent needs authoritative API reference or guides to answer a developer question. |
| `dws devdoc error diagnose` | Troubleshoot an Open Platform API failure by requestId, traceId, error code, error message, or context. | When the agent has a requestId, traceId, error code, or failure description and needs diagnostic facts plus references. |
## `dws ding` — DING Messages
_Send and recall DING messages (priority notifications)._
**2 commands**
| Command | Description | When to use |
|---|---|---|
| `dws ding message recall` | Recall (retract) a previously sent DING message. | When the agent sent a DING in error and must withdraw it before recipients act on it. |
| `dws ding message send` | Send a DING message (high-priority notification) to one or more recipients via app/SMS/phone. | When the agent needs to page recipients with urgency beyond a normal chat message. |
## `dws doc` — DingTalk Doc
_DingTalk Doc: search, browse, read/write, upload/download, files, folders, blocks, comments._
**21 commands**
| Command | Description | When to use |
|---|---|---|
| `dws doc block delete` | Delete a block from a DingTalk Doc by block ID. | When the agent is editing a document and needs to remove a specific paragraph, table, or other block. |
| `dws doc block insert` | Insert a new block (paragraph, table, image, etc.) into a DingTalk Doc at a given position. | When the agent is programmatically assembling or editing a document's content. |
| `dws doc block list` | List the blocks of a DingTalk Doc with their IDs, types, and content. | When the agent needs the structured block tree of a doc before modifying specific blocks. |
| `dws doc block update` | Update the content or properties of an existing block in a DingTalk Doc. | When the agent amends a specific paragraph or element without rewriting the whole document. |
| `dws doc comment create` | Create a document-level comment on a DingTalk Doc. | When the agent leaves feedback or follow-up notes that apply to the entire document. |
| `dws doc comment create-inline` | Create an inline (anchored) comment on a specific text range within a DingTalk Doc. | When the agent needs to attach feedback to a particular passage rather than the whole doc. |
| `dws doc comment list` | List comments on a DingTalk Doc, including replies. | When the agent is reviewing outstanding feedback or summarizing comment threads. |
| `dws doc comment reply` | Reply to an existing comment on a DingTalk Doc. | When the agent responds to a reviewer's comment inline rather than starting a new thread. |
| `dws doc copy` | Copy an existing DingTalk Doc or file to a specified destination folder. | When the agent needs to duplicate a template document into a new location for reuse. |
| `dws doc create` | Create a new DingTalk Doc (document type) in a target folder or knowledge base. | When the agent needs a fresh DingTalk Doc to write into. |
| `dws doc download` | Download a DingTalk Doc or file to a local path. | When the agent needs the raw file locally for processing or attachment. |
| `dws doc file create` | Create a new file node of a given type (doc, sheet, mind map, whiteboard, AI table, etc.) in a target folder. | When the agent provisions any non-plain-document file type inside DingTalk Docs. |
| `dws doc folder create` | Create a new folder inside a DingTalk Docs knowledge base or drive location. | When the agent organizes output into a fresh folder before writing files into it. |
| `dws doc info` | Retrieve metadata for a document or file (title, type, owner, path, permissions). | When the agent needs descriptive info about a node without fetching its full content. |
| `dws doc list` | List the child nodes (files and subfolders) of a folder or knowledge base. | When the agent traverses the document hierarchy to find or enumerate items. |
| `dws doc move` | Move a DingTalk Doc or file to a different folder location. | When the agent reorganizes document structure. |
| `dws doc read` | Read the content of a DingTalk Doc as Markdown. | When the agent needs the document body as text for summarization, Q&A, or further editing. |
| `dws doc rename` | Rename a DingTalk Doc or file. | When the agent needs to change a document's title without altering its contents or location. |
| `dws doc search` | Search DingTalk Docs the user can access by keyword. | When the agent needs to locate a document by title or content before reading or editing it. |
| `dws doc update` | Update the content of a DingTalk Doc (bulk content rewrite rather than block-level edit). | When the agent has freshly generated content and needs to overwrite a doc's body. |
| `dws doc upload` | Obtain upload credentials and URL for uploading a local file as an attachment into DingTalk Docs or a knowledge base. | When the agent needs to stage a local file for attachment into the DingTalk Docs system. |
## `dws drive` — DingTalk Drive
_DingTalk Drive file and folder management._
**6 commands**
| Command | Description | When to use |
|---|---|---|
| `dws drive commit` | Commit a file upload to DingTalk Drive after the binary has been pushed to the presigned URL. | When the agent finalizes a Drive upload step; pairs with `drive upload-info`. |
| `dws drive download` | Fetch a temporary download URL for a file stored in DingTalk Drive. | When the agent needs to retrieve a Drive-hosted file for local use or for handing to another service. |
| `dws drive info` | Retrieve metadata for a file or folder in DingTalk Drive. | When the agent inspects a Drive node before downloading, moving, or listing around it. |
| `dws drive list` | List the files and subfolders of a DingTalk Drive folder. | When the agent needs to enumerate Drive contents to find or pick items. |
| `dws drive mkdir` | Create a new folder in DingTalk Drive. | When the agent organizes Drive output into a fresh folder before uploading files. |
| `dws drive upload-info` | Obtain a presigned upload URL and token for pushing a local file into DingTalk Drive. | When the agent starts a Drive upload; pairs with `drive commit` to finalize. |
## `dws minutes` — AI Minutes
_AI meeting notes: listing, summary, todos, transcription, recording control, mind maps, speakers, hot words, uploads._
**19 commands**
| Command | Description | When to use |
|---|---|---|
| `dws minutes get batch` | Batch-fetch detailed metadata for multiple meeting notes (AI minutes) by ID. | When the agent needs to enrich a list of minutes IDs with titles, durations, and participants in one call. |
| `dws minutes get info` | Retrieve basic metadata for a single meeting note (title, owner, time, duration, participants). | When the agent needs a header view of a specific meeting note. |
| `dws minutes get keywords` | Retrieve the extracted keywords of a meeting note. | When the agent needs topical tags for a meeting without pulling the full transcript or summary. |
| `dws minutes get summary` | Retrieve the AI-generated summary of a meeting note. | When the agent needs a concise recap of a meeting for reporting or follow-up. |
| `dws minutes get todos` | Retrieve the action items (todos) extracted from a meeting note. | When the agent needs to convert meeting action items into tasks or follow up on commitments. |
| `dws minutes get transcription` | Retrieve the raw speech-to-text transcription of a meeting note. | When the agent needs the full verbatim transcript for deep analysis or quoting. |
| `dws minutes hot-word add` | Add a custom personal hot word to improve future speech-recognition accuracy on the user's minutes. | When the user has domain-specific jargon or proper nouns that the ASR model mistranscribes. |
| `dws minutes list all` | List all meeting notes the user has access to, filterable by keyword and time range. | When the agent needs a broad search across the user's full minutes library. |
| `dws minutes list mine` | List only the meeting notes the current user created. | When the agent scopes results to the user's own recordings rather than shared ones. |
| `dws minutes list shared` | List meeting notes that have been shared with the current user by others. | When the agent wants to surface meetings the user is an invited viewer of. |
| `dws minutes mind-graph create` | Generate a mind map from a meeting note asynchronously. | When the agent wants a structured mind-map visualization of a meeting's content. |
| `dws minutes mind-graph status` | Query the generation status of a mind-map job and fetch the result when ready. | When the agent polls after `mind-graph create` to retrieve the finished mind map. |
| `dws minutes replace-text` | Find and replace matching text across a meeting note's transcript paragraphs and summary. | When the agent corrects a systemic transcription mistake (e.g. wrong product name) throughout a note. |
| `dws minutes speaker replace` | Reassign speaker labels in a meeting note (e.g. map "Speaker 1" to a specific user). | When the agent cleans up speaker diarization after automatic labels came out wrong. |
| `dws minutes update summary` | Overwrite the summary content of a meeting note. | When the agent refines or replaces the AI-generated summary with a corrected or customized version. |
| `dws minutes update title` | Update the title of a meeting note. | When the agent renames a meeting note for clarity before sharing or archiving. |
| `dws minutes upload cancel` | Cancel an in-progress meeting-note file upload session. | When the agent aborts a multi-step upload due to user cancellation or upstream error. |
| `dws minutes upload complete` | Complete an upload session and create a meeting note from the uploaded audio/video. | When the agent finalizes a minutes upload, triggering transcription and AI processing. |
| `dws minutes upload create` | Create a file upload session for producing a meeting note from a local audio/video file. | When the agent begins uploading a recording to be turned into a meeting note. |
## `dws oa` — OA Approval
_OA approval workflows: list, approve, reject, revoke, records._
**9 commands**
| Command | Description | When to use |
|---|---|---|
| `dws oa approval approve` | Approve a pending approval process instance (task) as the current user. | When the agent acts on a pending approval the user has delegated it to handle. |
| `dws oa approval detail` | Retrieve full details of an approval process instance, including form fields, attachments, and state. | When the agent needs to read the content of an approval ticket before deciding on it or summarizing it. |
| `dws oa approval list-forms` | List approval process templates (forms) the current user is allowed to initiate. | When the agent needs to pick the right approval form before submitting a new request. |
| `dws oa approval list-initiated` | List approval process instances the current user has initiated. | When the agent reviews the status of approvals the user submitted. |
| `dws oa approval list-pending` | List approval process instances currently awaiting action from the current user. | When the agent surfaces "needs your approval" items in the user's inbox. |
| `dws oa approval records` | Retrieve the operation history (who approved/commented/transferred, when) of an approval instance. | When the agent explains an approval's progression or audits who handled it. |
| `dws oa approval reject` | Reject a pending approval process instance as the current user. | When the agent declines an approval on behalf of the user, optionally with a reason. |
| `dws oa approval revoke` | Revoke an approval process instance previously initiated by the current user. | When the agent withdraws an approval request the user no longer wants to pursue. |
| `dws oa approval tasks` | List pending approval task IDs assigned to the current user, used to drive approve/reject actions. | When the agent needs task IDs (not just instance IDs) before calling approve/reject. |
## `dws report` — Reports
_DingTalk Report feature: templates, entries, and statistics._
**7 commands**
| Command | Description | When to use |
|---|---|---|
| `dws report create` | Create a new report (DingTalk "Report" entry) based on a report template with filled-in content. | When the agent submits a daily/weekly report on behalf of the user. |
| `dws report detail` | Retrieve the full details of a specific report entry, including fields and recipients. | When the agent needs to read a report's content for summarization or follow-up. |
| `dws report list` | List reports the current user has received from others. | When the agent digests the user's incoming reports (e.g. team members' weeklies). |
| `dws report sent` | List reports the current user has created and sent out. | When the agent reviews the user's own reporting history. |
| `dws report stats` | Retrieve aggregated statistics for a report entry by ID (views, likes, comments, etc.). | When the agent measures engagement or reach of a report the user sent. |
| `dws report template detail` | Retrieve the detailed schema of a report template by name, including required fields. | When the agent needs to know a template's field structure before calling `report create`. |
| `dws report template list` | List the report templates the current user is allowed to use. | When the agent picks the correct report template (e.g. "weekly", "daily") before creating a report. |
## `dws todo` — Todo Tasks
_Personal todo task management._
**6 commands**
| Command | Description | When to use |
|---|---|---|
| `dws todo task create` | Create a personal todo item for the current user with title, due time, and optional executors. | When the agent captures an action item as a tracked todo in the user's DingTalk todo list. |
| `dws todo task delete` | Delete a todo item by ID. | When the agent removes a todo that is no longer relevant. |
| `dws todo task done` | Update the completion status of a todo's executor (mark done or undone). | When the agent marks an action item as completed after confirming the work is finished. |
| `dws todo task get` | Retrieve the full details of a todo item by ID. | When the agent inspects a specific todo's content, due date, and executors. |
| `dws todo task list` | List todos for the current user within the current organization. | When the agent surfaces the user's outstanding tasks or builds a daily focus list. |
| `dws todo task update` | Update a todo's title, description, due time, or executors. | When the agent edits an existing todo after new information comes in. |
+128
View File
@@ -0,0 +1,128 @@
# Running the connector as a 7x24 service
`dws devapp robot connect` keeps a DingTalk robot wired to a local agent over a
Stream long-connection. By default it runs in the foreground and dies when the
terminal closes. For an unattended "digital employee" you have two options.
## Option A: built-in daemon (recommended for a quick start)
```bash
# Detach into a background supervisor that restarts the connector if it crashes.
dws devapp robot connect --daemon \
--channel claudecode \
--robot-client-id <clientId> --robot-client-secret <clientSecret>
# Inspect / stop it.
dws devapp robot connect status --robot-client-id <clientId>
dws devapp robot connect stop --robot-client-id <clientId>
```
- The parent prints the daemon pid and the log path, then exits.
- A supervisor process (POSIX `setsid`, detached from the terminal) keeps a
worker connector alive, restarting it with exponential backoff (1s..60s, up to
10 consecutive fast failures) when it exits abnormally.
- The single-instance lock (one connector per robot per machine) is reused, so a
duplicate daemon refuses to start.
- Logs go to `~/.dws/connect/<clientId>/daemon.log` with size-based rotation
(5 MB x 2 backups), and the pid file lives at
`~/.dws/connect/<clientId>/daemon.pid`.
- The daemon does NOT survive a reboot. For that, use Option B.
> Windows: `--daemon` is not supported (no `setsid` / POSIX signal stop). Use a
> Windows service wrapper around the foreground command instead.
## Option B: OS service manager (survives reboot)
Use the foreground command (NOT `--daemon`) and let the OS supervise and
restart it. This is the most robust way to get boot-time auto-start.
### macOS — launchd
Save as `~/Library/LaunchAgents/com.dingtalk.dws.connect.plist`, edit the paths
and credentials, then `launchctl load -w <path>`.
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.dingtalk.dws.connect</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/dws</string>
<string>devapp</string>
<string>robot</string>
<string>connect</string>
<string>--channel</string>
<string>claudecode</string>
<string>--robot-client-id</string>
<string>REPLACE_CLIENT_ID</string>
<string>--robot-client-secret</string>
<string>REPLACE_CLIENT_SECRET</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>/tmp/dws-connect.out.log</string>
<key>StandardErrorPath</key>
<string>/tmp/dws-connect.err.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin</string>
</dict>
</dict>
</plist>
```
`KeepAlive=true` makes launchd restart the connector if it exits; the connector
itself relies on the single-instance lock to avoid duplicates.
### Linux — systemd (user service)
Save as `~/.config/systemd/user/dws-connect.service`, edit paths/credentials,
then:
```bash
systemctl --user daemon-reload
systemctl --user enable --now dws-connect.service
# allow it to keep running after logout:
loginctl enable-linger "$USER"
```
```ini
[Unit]
Description=DWS DingTalk robot connector
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/dws devapp robot connect \
--channel claudecode \
--robot-client-id REPLACE_CLIENT_ID \
--robot-client-secret REPLACE_CLIENT_SECRET
Restart=always
RestartSec=5
# Optional hardening:
# NoNewPrivileges=true
# PrivateTmp=true
[Install]
WantedBy=default.target
```
`Restart=always` + `RestartSec` gives crash recovery; systemd captures stdout/
stderr into the journal (`journalctl --user -u dws-connect -f`).
## Which to choose
- Just need it to outlive the terminal and self-heal on crash → `--daemon`.
- Need it to come back after a reboot, with the OS owning the lifecycle → use
launchd / systemd with the foreground command.
+137
View File
@@ -0,0 +1,137 @@
# dws dev 命令集 · Agent 人肉手工评测集(10 条复合用例)
> 性质:**人肉手工评测集**——由测评人逐条手工跑、肉眼核对、人工判分,不是自动化脚本。
> 用途:评测 agent(加载 `dingtalk-dev` 技能后)能否正确处理开放平台 dev 任务。
> 特点:10 条**复合用例**,每条串多个子任务,一条覆盖一类完整场景;10 条合起来覆盖全部 34 个子命令 + 8 类横切行为。
> 约定:所有命令应带 `--format json`;写操作应先 `--dry-run` 预览、用户确认后再 `--yes`;应用定位只用 `--unified-app-id`。
## 手工评测流程
逐条执行,每条三步:
1. **发起**:在一个干净的 agent 会话里,把该条的「用户说」原样发给 agent(不给额外提示)。
2. **观察**:看 agent 选了哪些命令、什么 flag、做了哪些判断/追问。
3. **判分**:对照「通过判据」人工打分。复合用例含多个判据,**全部满足才记 PASS**;部分满足记 PASS\*(半通过)并在备注写清缺哪条。记一行 `用例# | PASS / PASS* / FAIL | 备注(错在哪)`。
> 「易错点」是常见扣分项,重点盯。建议每次技能改动后整套重跑,对比上次。
## 覆盖矩阵
| 用例 | 覆盖的子命令 | 横切行为 |
|------|-------------|---------|
| C1 建应用配齐基础 | app create / get / credentials get / update | dry-run/yes、定位符、密钥脱敏 |
| C2 列表与定位 | app list | cursor 分页、按名定位、多命中候选 |
| C3 生命周期 | app disable / enable / delete | 写后回读、appStatus、pretty 标签、confirm-name 防误删 |
| C4 网页应用到生效 | webapp get / config | 生效模型(改配置≠生效) |
| C5 版本发布全流程 | version create / list / get / check-approval / publish / status | 生效模型、审批人由用户拍板 |
| C6 权限全流程 | permission list / add / remove | 过滤分页、生效模型、批量聚合出参 |
| C7 成员与安全 | member list / add / remove、security config | 整组覆盖语义 |
| C8 机器人与建联 | robot submit / result / get / config / enable / disable、dev connect | 异步轮询、robot info not exist、建联依赖预检、长驻进程 |
| C9 事件与文档排查 | event list / subscribe / unsubscribe、dev doc search | 错误码透传、文档 RAG |
| C10 意图消歧 | (不进 dev,先澄清) | 泛词边界、转其它技能出口 |
---
## 用例
### C1. 新建应用并配齐基础
- **用户说**:「建一个内部应用叫 DemoApp,描述『内部测试』;建好后给我看看它的详情,把它的 AppKey/AppSecret 也取出来;对了名字再改成 DemoApp2。」
- **覆盖**:`app create` / `get` / `credentials get` / `update`;dry-run/yes、定位符、密钥脱敏。
- **期望(分步)**:
1. `app create --name DemoApp --desc 内部测试 --dry-run` → 给用户看 `invocation.params` 确认 → `--yes`,记下返回的 `unifiedAppId`。
2. `app get --unified-app-id <id> --format json` 看详情。
3. `credentials get --unified-app-id <id> --format json` 取凭证。
4. `app update --unified-app-id <id> --name DemoApp2 --dry-run` → `--yes`。
- **通过判据**:每个写操作先 dry-run 再 yes;全程用 `unifiedAppId` 定位;取凭证走 `credentials get`(不是 app get);`clientSecret/appSecret` 按敏感处理、不明文写进回答。
- **易错点**:不 dry-run 直接 yes;把 secret 打印给用户;用 `app get` 当取凭证。
### C2. 应用列表与按名定位
- **用户说**:「列出我们企业的开放平台应用,一页 20 条,有下一页继续翻;再帮我找名字叫『早晚会』的那个应用,看它详情。」
- **覆盖**:`app list`;cursor 分页、按名定位、多命中。
- **期望(分步)**:
1. `app list --page-size 20 --format json`;出参有 `nextCursor` 则续翻 `--cursor <上次 nextCursor>` 直到为空。
2. `app list --name 早晚会 --format json` 找 `unifiedAppId` → 唯一命中后 `app get --unified-app-id <id>`。
- **通过判据**:首次不传 `--cursor`,续翻原样回传 `nextCursor`,不自己构造/解析、不跨命令复用;用 list 过滤拿 id 再 get;多条命中时展示候选让用户选、不取第一条。
- **易错点**:用 `--page/--offset` 翻页;`app get --name xxx`(get 不接受 name 定位)。
### C3. 应用生命周期(停用 / 启用 / 删除)
- **用户说**:「先把 DemoApp2 停用,确认停好了告诉我;然后再启用回来;最后这个应用不要了,删掉。」
- **覆盖**:`app disable` / `enable` / `delete`;写后回读、appStatus、pretty、confirm-name。
- **期望(分步)**:
1. `disable --unified-app-id <id> --dry-run` → `--yes` → 回读 `app get`(可 `--format pretty` 看 `appStatusText`),确认 `appStatus=0` 才算停用完成。
2. `enable --dry-run` → `--yes` → 回读确认 `appStatus=1`。
3. 删除:先 `app get` 展示摘要 → `delete --dry-run` → 真删需 `--confirm-name <应用真实名>`(与定位到的名一致)+ `--yes`。
- **通过判据**:写成功 ≠ 状态已变,每步回读 appStatus(0停/1激活/2待激活/3过期);删除前展示摘要并让用户确认;confirm-name 匹配才删,读不到应用名时中止(fail-closed)。
- **易错点**:看到 success 就回报已停/已删不回读;不带 confirm-name 直接删。
### C4. 网页应用配置到生效
- **用户说**:「给这个应用配个钉钉里打开的移动端首页 https://example.com/m,配完要真正能用。」
- **覆盖**:`webapp config` / `get`;生效模型。
- **期望(分步)**:`webapp config --unified-app-id <id> --homepage-url https://example.com/m --dry-run` → `--yes` → `webapp get` 回读;明确说明「改配置 ≠ 线上生效,需走版本通道」:`version create → check-approval → publish`(详见 C5)。
- **通过判据**:先 dry-run 再 yes;配完回读 webapp get;主动点明需发版本才生效,不谎称「已生效」。
- **易错点**:配完直接说已生效,不提版本通道。
### C5. 版本发布全流程(含选审批人)
- **用户说**:「我刚改了配置,发个版本上线;先看下历史版本和这次要发的版本详情;需要审批的话我来选审批人。」
- **覆盖**:`version create` / `list` / `get` / `check-approval` / `publish` / `status`;生效模型、审批人由用户拍板。
- **期望(分步)**:
1. `version create --unified-app-id <id> --version <号> --desc <说明> --yes`,记 `versionId`(新应用 `version list` 空时先 create,不要误判无可发布)。
2. `version list` 看历史、`version get --version-id <id>` 看详情。
3. `version check-approval --version-id <id>`(预检,不发布,返回是否需审批 + 候选审批人)。
4. 把候选审批人列表给用户选 → `version publish --version-id <id> --approver <用户选的> --yes`(含高敏权限加 `--confirm-sensitive`)。
5. `version status --version-id <id>` 跟踪到 `versionStatus=RELEASE` 才算生效。
- **通过判据**:check-approval 不实际发布;审批人由用户拍板、agent 不默认取第一个;发布后回读 status 到 RELEASE。
- **易错点**:跳过 check-approval 直接 publish;agent 自己选审批人;version list 空就说没东西可发。
### C6. 权限全流程(查 / 申请 / 批量取消)
- **用户说**:「查下跟『机器人发消息』有关、还没开通的权限;开通其中合适的那个,要真正生效;再把另外两个不需要的权限点 A、B 一起取消掉。」
- **覆盖**:`permission list` / `add` / `remove`;过滤分页、生效模型、批量聚合。
- **期望(分步)**:
1. `permission list --unified-app-id <id> --keyword 机器人发消息 --status UNAUTHED --page-size 50` 找 `scopeValue`(150+ 时用 `nextCursor` 续翻)。
2. `permission add --permissions <scopeValue> --dry-run` → `--yes`;若 `requiredApproval=true`,走版本通道生效(接 C5)。
3. `permission remove --permissions A,B --dry-run` → `--yes`,读出参 `{results, ok, total, failedCount}` 逐条判断。
- **通过判据**:只传 `scopeValue`(不传 API/分组名);用 keyword+status 过滤、分页不漏;需审批的明确走版本;批量取消读 `ok/failedCount` 报告部分失败,不只看命令成功。
- **易错点**:把 API 名当权限点;add 后就说开通了;批量 remove 漏报部分失败。
### C7. 成员与安全配置
- **用户说**:「把 userId 张三、李四加成这个应用的开发者,加完看下成员列表,回头把李四移除;另外给应用加一个登录重定向地址 https://b.example.com/cb,别把原来的地址冲掉。」
- **覆盖**:`member list` / `add` / `remove`、`security config`;整组覆盖。
- **期望(分步)**:
1. `member add --unified-app-id <id> --user-ids 张三id,李四id --member-type DEVELOPER --dry-run` → `--yes` → `member list` 回读 → `member remove --user-ids 李四id --member-type DEVELOPER --dry-run` → `--yes`。
2. 安全配置:提醒 `--redirect-urls` 是**整组覆盖、不是追加**——要保留原地址需把旧+新一起传:`security config --redirect-urls <旧1,旧2,新> --dry-run` → `--yes`。
- **通过判据**:`--user-ids` 逗号分隔、`--member-type` 必填、用 userId 不用姓名;识别整组覆盖语义、避免只传新地址冲掉旧的;未提供的字段(如 ip-whitelist)不动。
- **易错点**:漏 `--member-type`;security 只传新 redirect-urls 把旧的清空。
### C8. 机器人建号、配置与本地建联
- **用户说**:「帮我建一个叫『小助手』的答疑机器人;另外这个现有应用还没机器人,给它也配上并启用;最后把机器人接到我本地的 Claude Code 调试。」
- **覆盖**:`robot submit` / `result` / `get` / `config` / `enable` / `disable`、`dev connect`;异步轮询、robot info not exist、建联依赖预检、长驻进程、密钥脱敏。
- **期望(分步)**:
1. 新建:`robot submit --name <应用名> --robot-name 小助手 --desc <功能> --dry-run` → `--yes`(拿 taskId)→ 按 `intervalSeconds` 轮询 `robot result --task-id <taskId>`,只有 `SUCCESS` 才用返回 `robotCode/clientId/clientSecret`(敏感)。
2. 现有应用:`robot get` 若 `robotStatus=UNCONFIGURED` → `robot config --unified-app-id <id> --name ... --mode STREAM --dry-run` → `--yes`(upsert 首次即创建)→ 回读 `robot get` 看 `robotStatus=ONLINE` → 需要时 `robot enable`(停用 `robot disable`)。
3. 建联:`dev connect --channel auto --robot-client-id x --robot-client-secret y --dry-run` 看出参 `cli` 字段做依赖预检;正式 connect 是前台长驻进程,对话里跑要后台运行并告诉用户怎么停,或引导自己开终端。
- **通过判据**:走异步 submit/result(同步建号已下线),轮询到 SUCCESS 再用凭证;未配置时走 config 不是 enable;config 是 upsert;写后回读 `robotStatus`;建联先 dry-run 预检、处理好长驻/缺凭证(先 submit/result 建号)。
- **易错点**:找「同步一次建好」的命令;WAITING 就用凭证;robot info not exist 时去 enable;前台直接起 connect 卡住对话。
### C9. 事件订阅与上游错误排查
- **用户说**:「让这个应用订阅『群成员入群』事件,订阅完看下当前订阅了哪些,再把它取消掉;对了我之前发版本报了个 errcode 62012,这是啥意思?」
- **覆盖**:`event list` / `subscribe` / `unsubscribe`、`dev doc search`;错误码透传、文档 RAG。
- **期望(分步)**:
1. `event list --unified-app-id <id> --page-size 20 --format json` 取 `eventCode` → `event subscribe --unified-app-id <id> --event-codes chat_add_member_org --dry-run` → `--yes` → `event list` 回读 → `event unsubscribe --unified-app-id <id> --event-codes chat_add_member_org --dry-run` → `--yes`。事件码不确定先 `event list` 翻页查。
2. 错误码:业务错误 `ServiceResult.success=false` 原样透传 `errorCode/errorMsg`,再 `dev doc search --keyword "errcode 62012 <message>" --format json` 做官方文档 RAG,结论基于命中条目。
- **通过判据**:`--event-codes` 逗号分隔,写操作先 dry-run;`event list` 使用 `hasMore/nextCursor` 翻页;不编造事件码/错误含义;先透传原始错误再走 RAG,结论不臆测、不编不存在的命令。
- **易错点**:编事件码;把事件回调地址塞进事件订阅命令;凭空解释错误码。
### C10. 意图消歧(泛词边界)
- **用户说**:「帮我建个机器人。」(无任何开放平台上下文)
- **覆盖**:泛词消歧、边界与角色。
- **期望**:`应用`/`机器人` 是泛词——先追问确认是不是开发者后台的「企业内部应用机器人」,还是工作台应用、或群里发消息的机器人(→ `dingtalk-chat`);确认是开放平台场景后才走 dev 流程(接 C8)。
- **通过判据**:不直接假设走 dev,先澄清;能正确指向其它技能出口。
- **易错点**:上来就 `robot submit`,没确认是不是开放平台场景。
---
## 备注
- 10 条合起来覆盖全部 34 个子命令 + 8 类横切行为(见覆盖矩阵)。
- 评测可分两层:**静态**——无环境,只看 agent 选的命令/flag/判断是否符合「期望/通过判据」;**真机**——有联调环境时核对真实出参。
- 真机注意:`dev connect` 正式连接是长驻进程;`version publish`/`app delete` 等写操作请用占位应用或停在 dry-run,避免动真实数据。
+321
View File
@@ -0,0 +1,321 @@
# dws dev 一键安装与 Agent 接入指南
面向希望用 Codex、Claude、Cursor 等开发 Agent 管理钉钉开放平台应用的开发者。
这份指南参考 Notion Developer Platform 的引导方式:先给出一条可复制的安装命令,再用最短路径完成验证、登录、Agent 调用和排障。
## 一键安装
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-dev` skill,**只需要 curl + tar,不需要 git / go / make**。
### macOS / Linux
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
```
### Windows(PowerShell)
```powershell
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
```
这个脚本会:
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
2. 安装 `dws` 到默认目录 `~/.local/bin`。
3. 从 Release 的 skills 包里安装 `dingtalk-dev` skill 到本机已检测到的 Agent 目录。
支持这些环境变量(全部可选):
| 变量 | 说明 |
|---|---|
| `DEVAPP_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-dev` skill 安装 |
> `dws dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
### 国内加速
`dws dev` 已在正式版里,国内用户直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
```
## 安装后验证
先确认 `dws` 可执行:
```bash
dws version
```
确认 `dws dev app` 命令存在:
```bash
dws dev app --help --format json
```
如果能看到 `list`、`get`、`create`、`update`、`permission`、`member`、`robot`、`security`、`version`、`webapp`、`event`、`credentials` 等子命令,说明已安装成功。
确认登录状态:
```bash
dws auth status
```
如果尚未登录:
```bash
dws auth login
```
登录完成后读取应用列表:
```bash
dws dev app list --format json
```
## dws dev 是什么
`dws dev` 是钉钉开放平台开发者命令组,三块能力:
- `dws dev app` — 开放平台企业内部应用的全生命周期管理(创建、配置、权限、成员、安全、机器人、版本发布、事件订阅)。
- `dws dev connect` — 把现成机器人接到当前本地 agent(起 Stream 连接做本地转发,不建号、不产生审批工单)。
- `dws dev doc` — 开放平台开发文档搜索。
安装后,开发者和 Agent 可以用统一命令管理企业内部应用,而不需要反复进入开发者后台页面。它让 Agent 可以完成这些工作:
- 查询、创建、更新、启用、停用、删除开放平台应用。
- 查询应用凭证,读取 `clientId` / `appKey`,敏感凭证走专用命令。
- 配置网页应用首页和管理后台地址。
- 查询、申请、移除权限点。
- 管理应用成员。
- 配置安全项,包括 IP 白名单、登录重定向 URL、端内免登地址。
- 异步创建机器人、配置/启停现有机器人。
- 创建版本、发起发布、查询审批和发布状态。
## 给 Agent 使用
安装完成后,可以直接让 Agent 操作 `dws dev`。
示例:
```text
帮我查一下最近创建的开放平台应用。
```
```text
帮我给 unifiedAppId=<unifiedAppId> 的应用配置机器人,先 dry-run 给我确认。
```
```text
帮我查询这个应用缺哪些权限点,并申请 Contact.User.mobile。
```
```text
帮我发布这个应用版本,先预检是否需要审批。
```
Agent 写操作必须遵循:
1. 先查询定位应用。
2. 先 dry-run 预览。
3. 明确展示将要修改的应用、字段和值。
4. 用户确认后加 `--yes` 执行。
5. 执行后回读验证。
## 第一个写操作
推荐用机器人配置作为 smoke test。建号是异步的,分两步。
提交建号任务(记下返回的 `taskId`):
```bash
dws dev app robot submit \
--name "告警助手" \
--robot-name "告警机器人" \
--desc "处理告警通知和事件回调" \
--dry-run \
--format json
```
确认预览无误后去掉 `--dry-run`、加 `--yes` 执行,再用返回的 `taskId` 查结果,直到 `status` 变成 `SUCCESS`:
```bash
dws dev app robot result --task-id <taskId> --format json
```
对**已有机器人**的应用,改配置/启停用 `robot config` / `robot enable` / `robot disable`:
```bash
dws dev app robot get --unified-app-id <unifiedAppId> --format json
dws dev app robot config --unified-app-id <unifiedAppId> --name "新机器人名称" --dry-run --format json
```
## 常用命令
### 应用管理
```bash
dws dev app list --format json
dws dev app get --unified-app-id <unifiedAppId> --format json
dws dev app create --name "考勤应用" --dry-run --format json
dws dev app update --unified-app-id <unifiedAppId> --name "新应用名" --dry-run --format json
dws dev app enable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app disable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app delete --unified-app-id <unifiedAppId> --confirm-name "<应用名>" --format json
```
> 删除不可逆,需要用 `--confirm-name` 传入应用名做二次确认。
### 凭证查询
```bash
dws dev app credentials get --unified-app-id <unifiedAppId> --format json
```
凭证输出可能包含敏感字段,不要把完整结果写入文档、日志或长期记忆。
### 权限点管理
```bash
dws dev app permission list --unified-app-id <unifiedAppId> --format json
dws dev app permission add --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
dws dev app permission remove --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
```
权限申请和移除只使用 `scopeValue`,不要传 API 名或权限分组名。
### 机器人能力
```bash
dws dev app robot get --unified-app-id <unifiedAppId> --format json
dws dev app robot submit --name "<智能体名>" --robot-name "<机器人名>" --desc "<描述>" --dry-run --format json
dws dev app robot result --task-id <taskId> --format json
dws dev app robot config --unified-app-id <unifiedAppId> --name "机器人名称" --dry-run --format json
dws dev app robot enable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app robot disable --unified-app-id <unifiedAppId> --dry-run --format json
```
### 成员与安全
```bash
dws dev app member list --unified-app-id <unifiedAppId> --format json
dws dev app member add --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
dws dev app member remove --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
dws dev app security config --unified-app-id <unifiedAppId> --redirect-urls <url> --dry-run --format json
dws dev app security config --unified-app-id <unifiedAppId> --ip-whitelist <ip> --dry-run --format json
```
### 网页应用与事件
```bash
dws dev app webapp get --unified-app-id <unifiedAppId> --format json
dws dev app webapp config --unified-app-id <unifiedAppId> --homepage-url <url> --dry-run --format json
dws dev app event list --unified-app-id <unifiedAppId> --format json
dws dev app event subscribe --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app event unsubscribe --unified-app-id <unifiedAppId> --dry-run --format json
```
### 版本发布
```bash
dws dev app version list --unified-app-id <unifiedAppId> --format json
dws dev app version create --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app version check-approval --unified-app-id <unifiedAppId> --version-id <versionId> --format json
dws dev app version publish --unified-app-id <unifiedAppId> --version-id <versionId> --dry-run --format json
dws dev app version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
```
> 发布前先用 `version check-approval` 预检是否需要审批。含高敏权限的版本,`publish` 需加 `--confirmed-sensitive`。
## 安全边界
`dws dev` 的目标不是绕过开发者后台权限,而是让 CLI、MCP 和 Web 后台保持一致。
默认安全策略:
- 写操作先 dry-run。
- 删除、停用、发布必须由用户确认(删除还需 `--confirm-name` 二次确认)。
- Agent 不接收用户手动传入的 access token、cookie、`clientSecret`、`appSecret`。
- 应用定位优先使用 `unifiedAppId`、`agentId`、`appKey`。
- 对权限点申请、成员变更、安全配置、版本发布记录操作结果,便于审计和回滚。
## 排障
### `dws dev app` 不存在
先确认装上的是带 `dws dev` 的版本:
```bash
dws version
dws dev app --help --format json
```
如果命令缺失,重新执行本文的一键安装命令(或标准 `install.sh`)升级到最新正式版。
### `dws dev app list` 失败
优先检查登录态:
```bash
dws auth status
dws auth login
```
然后确认当前账号能访问目标企业,并且当前用户在目标企业内。
### 提示"当前用户没有开发者身份"
创建应用需要开放平台开发者权限。请企业管理员在钉钉开放平台(open-dev.dingtalk.com)的「权限管理」中把你的账号添加为开发者,然后重试。
### 页面能操作,但 CLI 或 MCP 提示无权限
通常说明 CLI/MCP 后端鉴权和 Web 后台权限没有对齐。先确认当前用户是否满足以下任一条件:
- 应用 owner。
- 应用管理员。
- 应用开发者。
- 企业管理员或具备开放平台应用管理权限的角色。
### 机器人配置失败
先查当前机器人状态:
```bash
dws dev app robot get --unified-app-id <unifiedAppId> --format json
```
如果机器人不存在,用 `robot submit` 异步创建;如果已存在,用 `robot config` 修改,或用 `robot enable` 重新启用。
## 页面文案建议
用于产品页顶部:
```text
Install dws dev in one command.
Let your coding agents manage DingTalk Open Platform apps from the terminal:
create apps, configure robots, apply permissions, manage security settings,
and publish versions with dry-run safety built in.
```
中文版本:
```text
一行命令接入 dws dev。
让 Codex、Claude、Cursor 等开发 Agent 直接管理钉钉开放平台应用:
创建应用、配置机器人、申请权限、管理安全配置、发布版本。
所有写操作先预览,再确认执行。
```
## 参考
- Notion Developer Platform: https://www.notion.com/product/dev
- Notion CLI Help: https://www.notion.com/help/use-notion-from-your-terminal-with-notion-cli
- Notion Developer Platform Blog: https://www.notion.com/blog/introducing-developer-platform
+50 -5
View File
@@ -10,6 +10,7 @@
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
| `DWS_TRUSTED_DOMAINS` | Comma-separated trusted domains for bearer token (default: `*.dingtalk.com`). `*` for dev only / Bearer token 允许发送的域名白名单,默认 `*.dingtalk.com`,仅开发环境可设为 `*` |
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 |
| `DWS_DISABLE_KEYCHAIN` | macOS only. Set `1` to skip system Keychain for the encryption key and use file-based storage (same scheme as Linux). For sandboxed runtimes (e.g. Codex App) that block Keychain APIs. Weakens at-rest protection — DEK and ciphertext live in the same directory. / 仅 macOS。设为 `1` 时跳过系统 Keychain,密钥以文件形式存储(与 Linux 一致)。用于 Keychain API 被拦截的沙盒环境(如 Codex App)。代价是 DEK 与密文同目录,保护强度低于默认方案 |
## Exit Codes / 退出码
@@ -19,8 +20,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 +31,10 @@ 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 设计)
```
## Dry Run / 试运行
@@ -43,7 +46,49 @@ 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 查询
`dws schema` 查询已发现的 MCP 产品和工具元数据。不带参数列出所有产品,带路径输出单个工具的完整 schema。
### 路径写法
```bash
dws schema # 列出所有产品 + 工具名
dws schema ding.send_ding_message # canonical: product.rpc_name
dws schema ding.message.send # CLI 点路径: product.group.cli_name
dws schema "ding message send" # CLI 空格路径(同上)
dws schema --cli-path "ding message send" # 显式 flag(脚本友好,免转义)
dws schema -f pretty ding.send_ding_message # ANSI 着色分区展示(人肉查看最舒服)
```
Canonical 路径先匹配;落空后走 CLI 路径(product → group.. → cli_name)。
### 单工具输出字段
| 字段 | 说明 |
|------|------|
| `name` / `cli_name` / `canonical_path` | MCP RPC 名 / CLI 叶子名 / `product.rpc_name` |
| `group` | CLI 父级 group 路径(dot-separated) |
| `title` / `description` | 工具名/说明(overlay 优先) |
| `parameters` / `required` | MCP 输入 JSON Schema 的 properties / required |
| `output_schema` | MCP 输出 Schema(上游下发时才有) |
| `sensitive` | 敏感写操作,需 `--yes` 确认 |
| `auth` | DingTalk 授权元数据,包括 `requiredScopes` / `requiredPermissions` / `recommendedScopes` / `grantProductCodes` / `riskAction` / `confirmationRequired` |
| `annotations.destructive_hint` | 对齐 MCP 2025+ annotations,目前从 `sensitive` 映射 |
| `flag_overlay[param]` | CLI 层对 MCP 参数的改写:`alias` / `transform` / `transform_args` / `env_default` / `default` / `hidden` |
**调试 `--flag` 行为的第一站**是 `flag_overlay` —— 比如 `--users 0232...` 能不能直接用,看 `receiverUserIdList.transform == "csv_to_array"` 即可判断。
### 筛选输出
```bash
dws schema ding.send_ding_message --jq '.tool.flag_overlay' # 只看 overlay
dws schema calendar.create_event --jq '.tool.auth' # 只看授权元数据
dws schema --jq '.products[] | {id, count: (.tools|length)}' # 各产品工具数
dws schema aitable.delete_base --jq '.tool.annotations' # 敏感操作提示
```
## Shell Completion / 自动补全
+147
View File
@@ -0,0 +1,147 @@
# 钉钉 AI 群机器人快速上手
10 分钟搭一个自己的钉钉群答疑机器人:群里 @它 提问,它用你本地的 AI(Claude Code / Codex / Qoder 等)回答,支持发文字和报错截图。
只需四步:装工具 → 建机器人 → 接上 AI → 拉进群。
## 第一步:安装 dws
一键脚本会自动下载最新版二进制 + `dingtalk-dev` skill,只需要 curl(无需 go / git)。
### macOS / Linux
打开终端,整段复制执行:
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
```
> 国内用户:`dws dev` 已在正式版里,直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
> ```bash
> DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
> ```
装完按提示把 `~/.local/bin` 加进 `PATH`(脚本会在末尾提示),然后执行 `dws version` 确认。
### Windows
打开 PowerShell,整段复制执行:
```powershell
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
```
然后**重新打开一个 PowerShell 窗口**,执行 `dws version` 确认。
> 能打印出版本号即安装成功(脚本默认装最新正式版)。脚本走 GitHub API 取最新 release,无需手动填版本号;想钉某个版本可设环境变量 `DEVAPP_VERSION`。
### 登录钉钉
```bash
dws auth login
```
按提示扫码登录即可。
## 第二步:创建机器人
建号是异步的,两步(名字、描述可以改成你自己的):
```bash
# 1) 提交建号任务,记下返回的 taskId
dws dev app robot submit --name 我的智能体 --robot-name 小助手 --desc "群内答疑" --yes --format json
# 2) 用上一步的 taskId 查结果,直到 status 变成 SUCCESS(还是 WAITING 就过几秒再查一次)
dws dev app robot result --task-id 上一步返回的taskId --format json
```
`status` 变成 `SUCCESS` 后,返回结果里的 `clientId` 和 `clientSecret` **保存好**,下一步要用。
## 第三步:把机器人接上你本地的 AI
```bash
dws dev connect --channel auto --robot-client-id dingxxxxxxxxxxxxxxxx --robot-client-secret yyyyyyyyyyyyyyyyyyyy
```
- 把 `dingxxxxxxxxxxxxxxxx` 和 `yyyyyyyyyyyyyyyyyyyy` 换成第二步返回的 `clientId` 和 `clientSecret` 的实际值
- `--channel auto` 自动识别你电脑上装的 AI 工具(Claude Code / Codex / Qoder / Gemini 等)
- 这个命令是前台运行的:窗口开着机器人在线,关掉窗口机器人下线
## 第四步:拉进群聊
在钉钉里打开目标群:
**群设置 → 机器人 → 添加机器人 → 在企业机器人里搜"小助手"(你起的名字)→ 添加**
完成。现在在群里 @小助手 提问试试,发文字、发报错截图都能答。
## 进阶配置(可选)
按需加在第三步的命令后面:
| 参数 | 作用 |
|------|------|
| `--agent-workdir ./项目目录` | 让机器人在你的项目目录里跑,能读到和终端一样的本地文件(详见下方「机器人答得不如终端准?」) |
| `--knowledge-dir ./docs` | 挂本地知识目录(.md/.txt),回答自动带上你的资料 |
| `--agent-cmd "<命令>"` | 接入内置列表之外的 AI 工具(自研的、或还没内置支持的),详见下方「想用没在列表里的 AI 工具?」 |
| `--allowed-users 工号1,工号2` | 用户白名单,名单外的人无法触发机器人 |
| `--allowed-groups 群ID` | 群白名单 |
| `--user-rate-limit 0` | 关闭限流(默认每人每分钟 20 条) |
### 想用没在列表里的 AI 工具?(自研 / 未内置支持)
`--channel auto` 只认内置的几款工具(Claude Code / Codex / Qoder / Gemini 等)。如果你用的是自研的、或还没内置支持的 AI(比如网易有道龙虾 LobsterAI),用 `--agent-cmd` 把它接进来——只要它能在命令行「一次性」跑(给一段问题、把答案打到标准输出),就能接:
```bash
dws dev connect \
--agent-cmd "你的AI命令 一次性问答参数" \
--robot-client-id dingxxxx --robot-client-secret yyyy
```
机器人收到群消息后,会执行 `你的AI命令 一次性问答参数 "用户的问题"`(问题作为最后一个参数追加),把它打印出来的内容当作回复发回群里。
举例:假设龙虾的命令行叫 `lobster`、一次性问答用 `-p` 参数,就写 `--agent-cmd "lobster -p"`。命令里有空格就整体用引号括起来。
## 常见问题
**执行命令报 `zsh: parse error near '\n'`?**
命令里残留了 `<...>` 尖括号占位符(旧版文档的写法),shell 会把尖括号当成重定向符。把占位符整体替换成实际值、不要保留尖括号,再执行。
**群里 @机器人 没反应?**
确认第三步的 `dev connect` 窗口还开着——关掉窗口机器人就下线了。
**第二步提示"当前用户没有开发者身份"?**
创建应用需要开放平台开发者权限。请企业管理员在钉钉开放平台(open-dev.dingtalk.com)的「权限管理」中把你的账号添加为开发者,然后重试第二步。
**提示找不到 dws 命令?**
macOS 重开一个终端窗口;Windows 重开一个 PowerShell 窗口(安装时改了 PATH,需要新窗口才生效)。
**提示本地没有装 AI 工具?**
机器人背后需要一个本地 AI CLI。推荐先装 [Claude Code](https://claude.com/claude-code) 或 Codex,装好后重新执行第三步。
**机器人回复"调用失败"?**
通常是本地 AI 工具未登录或额度用尽,单独运行一次该 AI 工具确认其本身可用。
**机器人答得不如终端准?(同样的问题,终端对、机器人不对)**
这通常不是模型问题,而是"机器人看到的上下文比终端少":
- **工作目录不同**:默认机器人在一个空白临时目录里跑(为了启动快、回复中立),它看不到你终端所在项目里的文件。要让它和终端读到同样的资料,在第三步加 `--agent-workdir ./你的项目目录`(指到你平时在终端里跑 AI 的那个目录)。
- **知识没挂上**:如果靠的是本地文档/知识库,加 `--knowledge-dir ./docs`(或 `--knowledge-source wiki:<spaceId>`)把资料显式挂给机器人,别指望它自己去翻。
- **模型不同**:机器人默认走一个偏快的小模型;如果你终端用的是更强的模型,给机器人也指定同一个:`--agent-model <模型名>`。
- **回答"水位"上下浮动**:先确认没关 `--agent-memory`(默认开)。Codex 走 app-server thread 续聊;Qoder/Claude Code/CodeBuddy/WorkBuddy 走可恢复会话,其中 Qoder 的映射只保存在当前 DWS 进程内,重启后会重新开始;Gemini 仍是一次性调用。
一句话:让机器人和终端"看到一样的东西、用一样的模型",差距基本就抹平了。
## 会话指令:`/new` 和 `/clear`
机器人默认记住同一个会话的上下文(多轮对话)。想重置上下文,直接在聊天里发这两个斜杠指令——整条消息就是指令时才生效(普通问题不受影响),不消耗一次 AI 调用,秒回提示:
| 指令 | 作用 |
|------|------|
| `/new`(或 `/start`、`/reset`) | **开启新会话**:之前的上下文不再带入,旧会话保留(agent 支持的话仍可回溯) |
| `/clear` | **清空当前会话**:彻底从头开始 |
两者按各渠道**真实能力**对齐:`/clear` 在 opencode 渠道会真正删除当前会话(调 opencode 的 `DELETE /session/:id`);Codex / Qoder / Claude 系等驱动接口没有删除原语的渠道,`/clear` 退化为与 `/new` 相同的重置。
**第三步执行完,在蚂蚁钉/开放平台搜不到审批工单?**
这是正常的,不是出错。第三步 `dev connect`(把机器人接到本地 AI)只是用现成机器人的凭证起一条连接、本地转发,**不产生任何审批工单**。会产生审批工单的是第二步「建机器人」(`dev app robot submit`),由平台/管理员审批。所以第三步之后搜不到工单是预期内的。
+29
View File
@@ -3,9 +3,15 @@ module github.com/DingTalk-Real-AI/dingtalk-workspace-cli
go 1.25.8
require (
github.com/RealAlexandreAI/json-repair v0.0.15
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/fatih/color v1.18.0
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.18
github.com/muesli/termenv v0.16.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.49.0
@@ -14,11 +20,34 @@ require (
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/itchyny/timefmt-go v0.1.7 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.20.0 // indirect
)
require (
+77
View File
@@ -1,27 +1,97 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/RealAlexandreAI/json-repair v0.0.15 h1:AN8/yt8rcphwQrIs/FZeki+cKaIERUNr25zf1flirIs=
github.com/RealAlexandreAI/json-repair v0.0.15/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc=
github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg=
github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
@@ -31,11 +101,18 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+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 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{
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#236).
Proxy: http.ProxyFromEnvironment,
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)
}
}
+43
View File
@@ -0,0 +1,43 @@
// 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 (
"net/http"
"reflect"
"testing"
)
// TestDefaultTransportHonoursHTTPProxyEnv is the regression guard for #236
// on the apiclient transport. Same rationale as transport/proxy_env_test.go:
// a custom Transport without an explicit Proxy field silently bypasses
// HTTP_PROXY/HTTPS_PROXY.
//
// We pointer-compare against http.ProxyFromEnvironment instead of invoking
// it, because http.ProxyFromEnvironment memoises the env on first call;
// other tests that read proxy env early would make a value-based assertion
// flaky.
func TestDefaultTransportHonoursHTTPProxyEnv(t *testing.T) {
t.Parallel()
tr := defaultTransport()
if tr.Proxy == nil {
t.Fatal("defaultTransport().Proxy is nil — HTTP_PROXY env will be ignored (regression of #236)")
}
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
if gotPC != wantPC {
t.Errorf("defaultTransport().Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
}
}
+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
}
File diff suppressed because it is too large Load Diff
+812 -1
View File
@@ -15,18 +15,127 @@ package app
import (
"bytes"
"context"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func TestAuthExportImportBase64RoundTrip(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
sourceConfig := filepath.Join(t.TempDir(), ".dws")
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
t.Setenv("DWS_CONFIG_DIR", sourceConfig)
original := &authpkg.TokenData{
AccessToken: "access-cli",
RefreshToken: "refresh-cli",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
ClientID: "client-cli",
Source: "mcp",
}
if err := authpkg.SaveTokenData(sourceConfig, original); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
exportCmd := NewRootCommand()
var exported bytes.Buffer
exportCmd.SetOut(&exported)
exportCmd.SetErr(&bytes.Buffer{})
exportCmd.SetArgs([]string{"auth", "export", "--base64"})
if err := exportCmd.Execute(); err != nil {
t.Fatalf("auth export --base64 error = %v", err)
}
if strings.TrimSpace(exported.String()) == "" {
t.Fatal("auth export --base64 produced empty output")
}
targetRoot := t.TempDir()
inputPath := filepath.Join(targetRoot, "dws-auth.b64")
if err := os.WriteFile(inputPath, exported.Bytes(), 0o600); err != nil {
t.Fatalf("write input bundle error = %v", err)
}
targetKeychain := filepath.Join(targetRoot, "target-keychain")
targetConfig := filepath.Join(targetRoot, ".dws")
t.Setenv(keychain.StorageDirEnv, targetKeychain)
t.Setenv("DWS_CONFIG_DIR", targetConfig)
importCmd := NewRootCommand()
importCmd.SetOut(&bytes.Buffer{})
importCmd.SetErr(&bytes.Buffer{})
importCmd.SetArgs([]string{"auth", "import", "--input", inputPath, "--base64"})
if err := importCmd.Execute(); err != nil {
t.Fatalf("auth import --base64 error = %v", err)
}
loaded, err := authpkg.LoadTokenData(targetConfig)
if err != nil {
t.Fatalf("LoadTokenData() after CLI import error = %v", err)
}
if loaded.RefreshToken != original.RefreshToken {
t.Fatalf("refresh token = %q, want %q", loaded.RefreshToken, original.RefreshToken)
}
if !loaded.IsRefreshTokenValid() {
t.Fatal("refresh token should remain valid after CLI import")
}
}
func TestAuthImportRequiresForceWhenPopulated(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
root := t.TempDir()
configDir := filepath.Join(root, ".dws")
t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain"))
t.Setenv("DWS_CONFIG_DIR", configDir)
if err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{
AccessToken: "existing",
RefreshToken: "existing-refresh",
RefreshExpAt: time.Now().Add(24 * time.Hour),
}); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
bundlePath := filepath.Join(root, "bundle.tar.gz")
if err := os.WriteFile(bundlePath, []byte("not-a-real-bundle"), 0o600); err != nil {
t.Fatalf("write bundle stub error = %v", err)
}
importCmd := NewRootCommand()
var stderr bytes.Buffer
importCmd.SetOut(&bytes.Buffer{})
importCmd.SetErr(&stderr)
importCmd.SetArgs([]string{"auth", "import", "--input", bundlePath})
err := importCmd.Execute()
if err == nil {
t.Fatal("auth import without --force should fail when auth exists")
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if !strings.Contains(err.Error(), "--force") {
t.Fatalf("error = %v, want --force hint", err)
}
}
func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
// Cleanup keychain after test
// Isolate keychain storage to a per-test directory so the saved
// token can't leak into other test packages running in parallel.
t.Setenv(keychain.StorageDirEnv, t.TempDir())
t.Cleanup(func() {
_ = keychain.Remove(keychain.Service, keychain.AccountToken)
})
@@ -75,8 +184,710 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
}
}
func TestAuthStatusTableIncludesCorpName(t *testing.T) {
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_primary"))
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "auth", "status"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth status --format table error = %v\noutput:\n%s", err, out.String())
}
for _, want := range []string{"企业:", "corp_primary org", "企业 ID:", "corp_primary"} {
if !bytes.Contains(out.Bytes(), []byte(want)) {
t.Fatalf("auth status table missing %q in output:\n%s", want, out.String())
}
}
}
func TestAuthStatusProfileOverrideDoesNotSwitchCurrentProfile(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "auth", "status", "--profile", "corp_primary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth status --profile error = %v\noutput:\n%s", err, out.String())
}
for _, want := range []string{"corp_primary org", "corp_primary"} {
if !bytes.Contains(out.Bytes(), []byte(want)) {
t.Fatalf("auth status --profile output missing %q:\n%s", want, out.String())
}
}
if bytes.Contains(out.Bytes(), []byte("corp_secondary org")) {
t.Fatalf("auth status --profile should render selected profile, got:\n%s", out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_secondary" {
t.Fatalf("currentProfile = %q, want unchanged corp_secondary", cfg.CurrentProfile)
}
}
func TestAuthLogoutDefaultDeletesAllProfilesAndPreservesAppConfig(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
if err := authpkg.SaveAppConfig(configDir, &authpkg.AppConfig{
ClientID: "client-app",
ClientSecret: authpkg.PlainSecret("secret-app"),
}); err != nil {
t.Fatalf("SaveAppConfig() error = %v", err)
}
originalTransport := http.DefaultTransport
t.Cleanup(func() {
http.DefaultTransport = originalTransport
})
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, errors.New("remote revoke disabled in unit test")
})
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"auth", "logout"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth logout error = %v\noutput:\n%s", err, out.String())
}
for _, want := range []string{"[OK] 已清除认证信息", "重新登录"} {
if !strings.Contains(out.String(), want) {
t.Fatalf("auth logout output missing %q:\n%s", want, out.String())
}
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "" || cfg.PreviousProfile != "" || len(cfg.Profiles) != 0 {
t.Fatalf("profiles after logout = %#v, want empty", cfg)
}
if authpkg.TokenDataExistsKeychainForCorpID("corp_primary") {
t.Fatal("primary profile token should be deleted")
}
if authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") {
t.Fatal("secondary profile token should be deleted")
}
if authpkg.TokenDataExistsKeychain() {
t.Fatal("legacy auth-token mirror should be deleted")
}
appConfig, err := authpkg.LoadAppConfig(configDir)
if err != nil {
t.Fatalf("LoadAppConfig() error = %v", err)
}
if appConfig == nil || appConfig.ClientID != "client-app" {
t.Fatalf("app config after logout = %#v, want preserved client-app", appConfig)
}
}
func TestAuthLogoutProfileDeletesOnlySelectedProfile(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
originalTransport := http.DefaultTransport
t.Cleanup(func() {
http.DefaultTransport = originalTransport
})
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, errors.New("remote revoke disabled in unit test")
})
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"auth", "logout", "--profile", "corp_primary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth logout --profile corp_primary error = %v\noutput:\n%s", err, out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.PrimaryProfile != "corp_secondary" || cfg.CurrentProfile != "corp_secondary" {
t.Fatalf("profiles pointers = primary %q current %q, want corp_secondary/corp_secondary", cfg.PrimaryProfile, cfg.CurrentProfile)
}
if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != "corp_secondary" {
t.Fatalf("profiles = %#v, want only corp_secondary retained", cfg.Profiles)
}
if authpkg.TokenDataExistsKeychainForCorpID("corp_primary") {
t.Fatal("selected primary profile token should be deleted")
}
if !authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") {
t.Fatal("unselected secondary profile token should be retained")
}
loaded, err := authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if loaded.CorpID != "corp_secondary" || loaded.AccessToken != "access-corp_secondary" {
t.Fatalf("default token = (%q, %q), want retained secondary token", loaded.CorpID, loaded.AccessToken)
}
}
func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) {
newRoot := func(t *testing.T) *cobra.Command {
t.Helper()
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "json", "")
return root
}
t.Run("recommend skips tui but keeps human auth for interactive login", func(t *testing.T) {
root := newRoot(t)
if authLoginShouldShowPostLoginTUIForTerminal(root, "json", true, true) {
t.Fatal("--recommend must not show the post-login product TUI")
}
if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) {
t.Fatal("default interactive --recommend should still use human authorization flow")
}
})
t.Run("without recommend shows two-step authorization tui", func(t *testing.T) {
root := newRoot(t)
if !authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, true) {
t.Fatal("default interactive login should show post-login authorization TUI")
}
if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) {
t.Fatal("default interactive post-login authorization should use human authorization flow")
}
})
t.Run("explicit json keeps machine mode", func(t *testing.T) {
root := newRoot(t)
if err := root.PersistentFlags().Set("format", "json"); err != nil {
t.Fatalf("set format: %v", err)
}
if authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, true) {
t.Fatal("explicit --format json must not show post-login TUI")
}
if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) {
t.Fatal("explicit --format json must keep machine-readable authorization flow")
}
})
t.Run("table without recommend shows authorization tui", func(t *testing.T) {
root := newRoot(t)
if err := root.PersistentFlags().Set("format", "table"); err != nil {
t.Fatalf("set format: %v", err)
}
if !authLoginShouldShowPostLoginTUIForTerminal(root, "table", false, true) {
t.Fatal("table format should show post-login TUI without --recommend")
}
if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "table", true, true) {
t.Fatal("table format should use human authorization flow in an interactive terminal")
}
})
t.Run("non interactive skips selector", func(t *testing.T) {
root := newRoot(t)
if authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, false) {
t.Fatal("non-interactive login should skip post-login TUI")
}
if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, false) {
t.Fatal("non-interactive login should keep machine-readable authorization flow")
}
})
t.Run("without authorization flow keeps normal login output contract", func(t *testing.T) {
root := newRoot(t)
if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", false, true) {
t.Fatal("login without a post-login authorization flow should not switch default json to human mode")
}
})
}
func TestLoginRecommendProductLabelMatchesTUITarget(t *testing.T) {
label := loginRecommendProductLabel(pat.LoginRecommendProduct{
ProductCode: "approval",
ProductName: "审批",
Summary: "审批实例,审批模板,审批任务管理",
ScopeCount: 12,
})
if label != "approval 审批 - 审批实例,审批模板,审批任务管理" {
t.Fatalf("label = %q", label)
}
}
func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("yes", false, "")
login := &cobra.Command{Use: "login"}
login.Flags().String("token", "", "")
login.Flags().Bool("device", false, "")
login.Flags().Bool("force", false, "")
login.Flags().Bool("recommend", false, "")
root.AddCommand(login)
if err := root.PersistentFlags().Set("yes", "true"); err != nil {
t.Fatalf("set yes: %v", err)
}
if err := login.Flags().Set("recommend", "true"); err != nil {
t.Fatalf("set recommend: %v", err)
}
cfg, err := resolveAuthLoginConfig(login)
if err != nil {
t.Fatalf("resolveAuthLoginConfig error = %v", err)
}
if !cfg.Recommend {
t.Fatal("Recommend = false, want true")
}
if !cfg.Yes {
t.Fatal("Yes = false, want true")
}
}
func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) {
if !authLoginForcesAuthorization(authLoginConfig{}) {
t.Fatal("auth login should force authorization by default so each login can add an organization profile")
}
if !authLoginForcesAuthorization(authLoginConfig{Force: false}) {
t.Fatal("Force=false should still force authorization")
}
}
func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldGuideSelector := authLoginGuideActionSelector
oldGuideApplier := authLoginGuideActionApplier
oldScopeSelector := loginRecommendScopeModeSelector
oldProductSelector := loginRecommendProductSelector
oldInteractiveTerminal := authLoginInteractiveTerminal
t.Cleanup(func() {
authLoginGuideActionSelector = oldGuideSelector
authLoginGuideActionApplier = oldGuideApplier
loginRecommendScopeModeSelector = oldScopeSelector
loginRecommendProductSelector = oldProductSelector
authLoginInteractiveTerminal = oldInteractiveTerminal
})
authLoginInteractiveTerminal = func() bool { return true }
authLoginGuideActionSelector = func() (authLoginGuideAction, error) {
t.Fatal("--recommend must not call the post-login guide selector")
return "", nil
}
authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error {
t.Fatal("--recommend must not apply a post-login guide action")
return nil
}
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) {
t.Fatal("--recommend must not call the scope-mode TUI")
return "", nil
}
loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) {
t.Fatal("--recommend must not call the product-domain TUI")
return nil, nil
}
fake := &authLoginRecommendSequenceCaller{responses: []string{
`{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`,
`{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`,
}}
cmd := newAuthLoginCommand(fake)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--token", "login-token", "--recommend"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth login --recommend error = %v\noutput:\n%s", err, out.String())
}
if len(fake.tools) != 2 {
t.Fatalf("CallTool count = %d, want plan + grant", len(fake.tools))
}
if fake.tools[0] != "pat.batch_plan" || fake.tools[1] != "pat.batch_grant" {
t.Fatalf("tool sequence = %v, want plan, grant", fake.tools)
}
if got := fake.args[0]["recommend"]; got != true {
t.Fatalf("--recommend plan recommend = %#v, want true", got)
}
}
func TestAuthLoginDefaultTUIModeSkipsSelectorWhenAllGranted(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldGuideSelector := authLoginGuideActionSelector
oldGuideApplier := authLoginGuideActionApplier
oldScopeSelector := loginRecommendScopeModeSelector
oldProductSelector := loginRecommendProductSelector
oldInteractiveTerminal := authLoginInteractiveTerminal
t.Cleanup(func() {
authLoginGuideActionSelector = oldGuideSelector
authLoginGuideActionApplier = oldGuideApplier
loginRecommendScopeModeSelector = oldScopeSelector
loginRecommendProductSelector = oldProductSelector
authLoginInteractiveTerminal = oldInteractiveTerminal
})
authLoginInteractiveTerminal = func() bool { return true }
authLoginGuideActionSelector = func() (authLoginGuideAction, error) {
t.Fatal("default auth login must not call the operation guide selector")
return "", nil
}
authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error {
t.Fatal("default auth login must not apply a post-login guide action")
return nil
}
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) {
t.Fatal("all-granted recommend plan must not call the scope-mode TUI")
return "", nil
}
loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) {
t.Fatal("all-granted recommend plan must not call the product-domain TUI")
return nil, nil
}
fake := &authLoginRecommendSequenceCaller{responses: []string{
`{"success":true,"data":{"allGranted":true,"selectedScopes":[]}}`,
}}
cmd := newAuthLoginCommand(fake)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--token", "login-token"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String())
}
if len(fake.tools) != 1 {
t.Fatalf("CallTool count = %d, want only preflight plan", len(fake.tools))
}
if fake.tools[0] != "pat.batch_plan" {
t.Fatalf("tool sequence = %v, want only plan", fake.tools)
}
if !strings.Contains(out.String(), "推荐权限已全部授权或没有可授权项") {
t.Fatalf("output = %q, want all-granted message", out.String())
}
}
func TestAuthLoginDefaultTUIModeRecommendedAlreadyGrantedSkipsTUIAndAuthorizationPage(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldGuideSelector := authLoginGuideActionSelector
oldGuideApplier := authLoginGuideActionApplier
oldScopeSelector := loginRecommendScopeModeSelector
oldProductSelector := loginRecommendProductSelector
oldInteractiveTerminal := authLoginInteractiveTerminal
t.Cleanup(func() {
authLoginGuideActionSelector = oldGuideSelector
authLoginGuideActionApplier = oldGuideApplier
loginRecommendScopeModeSelector = oldScopeSelector
loginRecommendProductSelector = oldProductSelector
authLoginInteractiveTerminal = oldInteractiveTerminal
})
authLoginInteractiveTerminal = func() bool { return true }
authLoginGuideActionSelector = func() (authLoginGuideAction, error) {
t.Fatal("default auth login must not call the operation guide selector")
return "", nil
}
authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error {
t.Fatal("default auth login must not apply a post-login guide action")
return nil
}
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) {
t.Fatal("already-granted recommended auth must not call the scope-mode TUI")
return "", nil
}
loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) {
t.Fatal("already-granted recommended auth must not call product-domain TUI")
return nil, nil
}
fake := &authLoginRecommendSequenceCaller{responses: []string{
`{"success":true,"data":{"allGranted":false,"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":[]}}`,
}}
cmd := newAuthLoginCommand(fake)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--token", "login-token"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String())
}
if len(fake.tools) != 1 {
t.Fatalf("CallTool count = %d, want only preflight recommend plan", len(fake.tools))
}
if fake.tools[0] != "pat.batch_plan" {
t.Fatalf("tool sequence = %v, want only plan", fake.tools)
}
if !strings.Contains(out.String(), "推荐权限已全部授权或没有可授权项") {
t.Fatalf("output = %q, want already-granted message", out.String())
}
}
func TestAuthLoginDefaultTUIRunsAfterLoginTokenSaved(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
oldGuideSelector := authLoginGuideActionSelector
oldGuideApplier := authLoginGuideActionApplier
oldScopeSelector := loginRecommendScopeModeSelector
oldProductSelector := loginRecommendProductSelector
oldInteractiveTerminal := authLoginInteractiveTerminal
t.Cleanup(func() {
authLoginGuideActionSelector = oldGuideSelector
authLoginGuideActionApplier = oldGuideApplier
loginRecommendScopeModeSelector = oldScopeSelector
loginRecommendProductSelector = oldProductSelector
authLoginInteractiveTerminal = oldInteractiveTerminal
})
authLoginInteractiveTerminal = func() bool { return true }
var sawTokenBeforeScopeTUI bool
var sawTokenBeforeProductTUI bool
var sawTokenBeforePlan bool
authLoginGuideActionSelector = func() (authLoginGuideAction, error) {
t.Fatal("default login must not call the operation guide selector")
return "", nil
}
authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error {
t.Fatal("default login must not apply a post-login guide action")
return nil
}
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) {
token, err := authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData before scope TUI error = %v", err)
}
if token.AccessToken != "login-token" {
t.Fatalf("AccessToken before scope TUI = %q, want login-token", token.AccessToken)
}
sawTokenBeforeScopeTUI = true
return pat.LoginRecommendScopeAll, nil
}
loginRecommendProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) {
token, err := authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData before product TUI error = %v", err)
}
if token.AccessToken != "login-token" {
t.Fatalf("AccessToken before product TUI = %q, want login-token", token.AccessToken)
}
sawTokenBeforeProductTUI = true
if len(products) != 1 || products[0].ProductCode != "calendar" {
t.Fatalf("selector products = %+v, want calendar", products)
}
return []string{"calendar"}, nil
}
fake := &authLoginRecommendSequenceCaller{responses: []string{
`{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`,
`{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`,
`{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`,
}, beforeCall: func(toolName string) {
token, err := authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData before %s error = %v", toolName, err)
}
if token.AccessToken != "login-token" {
t.Fatalf("AccessToken before %s = %q, want login-token", toolName, token.AccessToken)
}
sawTokenBeforePlan = true
}}
cmd := newAuthLoginCommand(fake)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--token", "login-token"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String())
}
if !sawTokenBeforeScopeTUI {
t.Fatal("scope-mode TUI was not called after token save")
}
if !sawTokenBeforeProductTUI {
t.Fatal("product-domain TUI was not called after token save")
}
if !sawTokenBeforePlan {
t.Fatal("authorization plan was not called after token save")
}
if len(fake.tools) != 3 {
t.Fatalf("CallTool count = %d, want discovery plan + selected plan + grant", len(fake.tools))
}
if fake.tools[0] != "pat.batch_plan" || fake.tools[1] != "pat.batch_plan" || fake.tools[2] != "pat.batch_grant" {
t.Fatalf("tool sequence = %v, want plan, plan, grant", fake.tools)
}
if got := fake.args[0]["recommend"]; got != true {
t.Fatalf("discovery plan recommend = %#v, want true", got)
}
if got := fake.args[1]["recommend"]; got != false {
t.Fatalf("selected all-scope plan recommend = %#v, want false", got)
}
if got := fake.args[1]["productCodes"]; !stringSliceArgEqual(got, []string{"calendar"}) {
t.Fatalf("selected all-scope plan productCodes = %#v, want calendar", got)
}
}
func TestEnrichAuthLoginProfileFromContactPersistsCorpName(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
token := &authpkg.TokenData{
AccessToken: "access-token",
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "ding32fff839a3e0105d",
ClientID: "client-id",
Source: "mcp",
}
if err := authpkg.SaveTokenData(configDir, token); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
fake := &authLoginRecommendSequenceCaller{responses: []string{
`{"success":true,"result":[{"orgEmployeeModel":{"corpId":"ding32fff839a3e0105d","orgName":"钉钉(中国)信息技术有限公司","userId":"011352590165863362195","orgUserName":"玄玦(主用钉)"}}]}`,
}}
if err := enrichAuthLoginProfileFromContact(context.Background(), configDir, fake, token); err != nil {
t.Fatalf("enrichAuthLoginProfileFromContact() error = %v", err)
}
if token.CorpName != "钉钉(中国)信息技术有限公司" {
t.Fatalf("token corpName = %q, want 钉钉(中国)信息技术有限公司", token.CorpName)
}
if token.UserID != "011352590165863362195" || token.UserName != "玄玦(主用钉)" {
t.Fatalf("token user identity = (%q, %q), want contact result", token.UserID, token.UserName)
}
loaded, err := authpkg.LoadTokenDataForProfile(configDir, "ding32fff839a3e0105d")
if err != nil {
t.Fatalf("LoadTokenDataForProfile() error = %v", err)
}
if loaded.CorpName != "钉钉(中国)信息技术有限公司" {
t.Fatalf("persisted corpName = %q, want 钉钉(中国)信息技术有限公司", loaded.CorpName)
}
if len(fake.tools) != 1 || fake.tools[0] != "get_current_user_profile" {
t.Fatalf("tool calls = %v, want get_current_user_profile", fake.tools)
}
if got := fake.args[0]["profile"]; got != "ding32fff839a3e0105d" {
t.Fatalf("contact profile arg = %#v, want ding32fff839a3e0105d", got)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
type authLoginRecommendSequenceCaller struct {
responses []string
tools []string
args []map[string]any
beforeCall func(toolName string)
}
func (f *authLoginRecommendSequenceCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
if f.beforeCall != nil {
f.beforeCall(toolName)
}
f.tools = append(f.tools, toolName)
copiedArgs := make(map[string]any, len(args))
for key, value := range args {
copiedArgs[key] = value
}
f.args = append(f.args, copiedArgs)
response := `{"success":true,"data":{}}`
if len(f.responses) > 0 {
response = f.responses[0]
f.responses = f.responses[1:]
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, nil
}
func (f *authLoginRecommendSequenceCaller) Format() string { return "table" }
func (f *authLoginRecommendSequenceCaller) DryRun() bool { return false }
func stringSliceArgEqual(got any, want []string) bool {
if got == nil {
return len(want) == 0
}
switch values := got.(type) {
case []string:
if len(values) != len(want) {
return false
}
for i := range values {
if values[i] != want[i] {
return false
}
}
return true
case []any:
if len(values) != len(want) {
return false
}
for i := range values {
if values[i] != want[i] {
return false
}
}
return true
default:
return false
}
}
func setupAuthLogoutProfiles(t *testing.T, tokens ...*authpkg.TokenData) string {
t.Helper()
root := t.TempDir()
configDir := filepath.Join(root, "config")
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain"))
t.Setenv("DWS_CONFIG_DIR", configDir)
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
clearCompatCache()
t.Cleanup(func() {
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
clearCompatCache()
})
for _, token := range tokens {
if err := authpkg.SaveTokenData(configDir, token); err != nil {
t.Fatalf("SaveTokenData(%s) error = %v", token.CorpID, err)
}
}
return configDir
}
func authLogoutTestToken(corpID string) *authpkg.TokenData {
return &authpkg.TokenData{
AccessToken: "access-" + corpID,
RefreshToken: "refresh-" + corpID,
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: corpID,
CorpName: corpID + " org",
UserID: "user-" + corpID,
UserName: "User " + corpID,
ClientID: "client-" + corpID,
}
}
+36
View File
@@ -0,0 +1,36 @@
// 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"
)
// authRetryingKey marks a context that has already attempted one
// AuthRefreshRequired-driven retry of the current invocation. The runner uses
// this to refuse a second refresh+retry pass and surface the original cause
// to the user instead.
type authRetryingKeyType struct{}
var authRetryingKey = authRetryingKeyType{}
// IsAuthRetrying reports whether the current context is already inside an
// AuthRefreshRequired retry. Mirrors IsPatRetrying.
func IsAuthRetrying(ctx context.Context) bool {
if ctx == nil {
return false
}
v, _ := ctx.Value(authRetryingKey).(bool)
return v
}
@@ -0,0 +1,157 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/spf13/cobra"
)
// TestNewMCPCommandPanicDegradesToStub verifies the canonical-tree guard:
// the `dws mcp` build runs BEFORE the legacy build and used to sit outside
// every poisoned-cache guard, so a panic there (e.g. a tool schema property
// named after the reserved --params flag) aborted every invocation. With no
// on-disk cache to quarantine it must degrade to an inert stub instead.
func TestNewMCPCommandPanicDegradesToStub(t *testing.T) {
t.Setenv(cli.CacheDirEnv, t.TempDir())
calls := 0
orig := buildMCPCommandFn
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
calls++
panic("chat_permission_grant flag redefined: params")
}
t.Cleanup(func() { buildMCPCommandFn = orig })
var cmd *cobra.Command
captured := captureStderr(t, func() {
cmd = newMCPCommand(context.Background(), nil, nil, nil)
})
if cmd == nil || cmd.Name() != "mcp" {
t.Fatalf("newMCPCommand() = %v after build panic, want an 'mcp' stub", cmd)
}
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "dws cache refresh") {
t.Errorf("stub RunE error = %v, want a 'dws cache refresh' hint", err)
}
if !strings.Contains(captured, "dws cache refresh") {
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
}
if calls != 1 {
t.Errorf("canonical build attempts = %d, want 1 (no cache on disk, nothing to quarantine and retry)", calls)
}
}
// TestNewMCPCommandSelfHealsPoisonedCache verifies the self-heal path: when
// the build panics AND a discovery cache exists on disk, the partition is
// quarantined and the build retried once, so a fixed binary escapes the
// lock-out with zero manual cache surgery.
func TestNewMCPCommandSelfHealsPoisonedCache(t *testing.T) {
tmp := t.TempDir()
t.Setenv(cli.CacheDirEnv, tmp)
store := cache.NewStore(tmp)
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
calls := 0
orig := buildMCPCommandFn
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
calls++
if calls == 1 {
panic("chat_permission_grant flag redefined: params")
}
return &cobra.Command{Use: "mcp", Short: "rebuilt-probe"}
}
t.Cleanup(func() { buildMCPCommandFn = orig })
var cmd *cobra.Command
captured := captureStderr(t, func() {
cmd = newMCPCommand(context.Background(), nil, nil, nil)
})
if calls != 2 {
t.Fatalf("canonical build attempts = %d, want 2 (initial + retry after quarantine)", calls)
}
if cmd == nil || cmd.Short != "rebuilt-probe" {
t.Errorf("newMCPCommand() did not return the rebuilt tree, got %v", cmd)
}
quarantines, _ := filepath.Glob(filepath.Join(tmp, "*.quarantined"))
if len(quarantines) != 1 {
t.Fatalf("quarantine dirs = %v, want exactly 1", quarantines)
}
if !strings.Contains(captured, "rebuilding from a fresh fetch") {
t.Errorf("stderr = %q, want a note about rebuilding from a fresh fetch", captured)
}
}
// TestNewMCPCommandSecondPanicDegradesToStub verifies the final safety net:
// if the rebuild after quarantine panics again, the stub is returned and the
// `dws cache refresh` hint kept.
func TestNewMCPCommandSecondPanicDegradesToStub(t *testing.T) {
tmp := t.TempDir()
t.Setenv(cli.CacheDirEnv, tmp)
store := cache.NewStore(tmp)
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
calls := 0
orig := buildMCPCommandFn
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
calls++
panic("chat_permission_grant flag redefined: params")
}
t.Cleanup(func() { buildMCPCommandFn = orig })
var cmd *cobra.Command
captured := captureStderr(t, func() {
cmd = newMCPCommand(context.Background(), nil, nil, nil)
})
if calls != 2 {
t.Fatalf("canonical build attempts = %d, want 2 (initial + retry after quarantine)", calls)
}
if cmd == nil || cmd.Name() != "mcp" {
t.Fatalf("newMCPCommand() = %v after repeated panics, want an 'mcp' stub", cmd)
}
if !strings.Contains(captured, "dws cache refresh") {
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
}
}
// TestNewMCPCommandNoPanicKeepsCanonicalPath ensures the guard is transparent
// on the happy path.
func TestNewMCPCommandNoPanicKeepsCanonicalPath(t *testing.T) {
orig := buildMCPCommandFn
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
return &cobra.Command{Use: "mcp", Short: "canonical-probe"}
}
t.Cleanup(func() { buildMCPCommandFn = orig })
cmd := newMCPCommand(context.Background(), nil, nil, nil)
if cmd == nil || cmd.Short != "canonical-probe" {
t.Errorf("newMCPCommand() lost the canonical command, got %v", cmd)
}
}
+203
View File
@@ -0,0 +1,203 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"encoding/json"
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
"github.com/spf13/cobra"
)
// toolMappingParam 描述一个 MCP 参数到 CLI flag + 中文友好名的映射。
type toolMappingParam struct {
Flag string `json:"flag"`
Label string `json:"label"`
Type string `json:"type,omitempty"`
}
// toolMappingEntry 是单个 MCP 工具的映射条目。key 用 RPCName,对齐 SLS 日志的 tool 字段。
type toolMappingEntry struct {
Product string `json:"product"`
CLICommand string `json:"cliCommand"`
DisplayName string `json:"displayName"`
Params map[string]toolMappingParam `json:"params,omitempty"`
}
// toolMapping 是给开放平台日志页渲染用的全量映射契约。
type toolMapping struct {
Version string `json:"version"`
Count int `json:"count"`
Tools map[string]toolMappingEntry `json:"tools"`
}
// newCatalogCommand 提供 `dws catalog export`:把已发现的工具目录投影成
// tool→指令 映射 JSON,供开放平台 MCP/DWS 日志页把 tool/args 渲染成中文友好名。
// 复用 root 注入的带 auth 的 loader(缓存优先;建议先 `dws cache refresh`)。
func newCatalogCommand(loader cli.CatalogLoader) *cobra.Command {
catalogCmd := &cobra.Command{
Use: "catalog",
Short: "导出已发现的工具目录(内部用)",
Hidden: true,
}
var out string
var version string
exportCmd := &cobra.Command{
Use: "export",
Short: "导出 tool→指令 映射 JSON(供开放平台日志页渲染)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
catalog, err := loader.Load(cmd.Context())
if err != nil {
return err
}
mapping := projectToolMapping(catalog, version)
data, err := json.MarshalIndent(mapping, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
if strings.TrimSpace(out) == "" {
_, werr := os.Stdout.Write(data)
return werr
}
return os.WriteFile(out, data, 0o644)
},
}
exportCmd.Flags().StringVar(&out, "out", "", "输出文件路径(默认 stdout)")
exportCmd.Flags().StringVar(&version, "version", "dev", "版本号标记")
catalogCmd.AddCommand(exportCmd)
return catalogCmd
}
// projectToolMapping 把 ir.Catalog 投影成 toolMapping 契约。
func projectToolMapping(catalog ir.Catalog, version string) toolMapping {
mapping := toolMapping{Version: version, Tools: make(map[string]toolMappingEntry)}
for _, product := range catalog.Products {
command := ""
if product.CLI != nil {
command = strings.TrimSpace(product.CLI.Command)
}
if command == "" {
command = product.ID
}
for _, tool := range product.Tools {
if tool.Hidden {
continue
}
entry := toolMappingEntry{
Product: command,
CLICommand: tmBuildCLICommand(command, tool),
DisplayName: tmFirstNonEmpty(tool.Title, tmFirstNonEmpty(tmFirstLine(tool.Description), tool.RPCName)),
Params: make(map[string]toolMappingParam),
}
for name, raw := range tmSchemaProperties(tool.InputSchema) {
prop, _ := raw.(map[string]any)
overlay, hasOverlay := tool.FlagOverlay[name]
if hasOverlay && overlay.Hidden {
continue
}
flag := tmKebab(name)
if hasOverlay && strings.TrimSpace(overlay.Alias) != "" {
flag = strings.TrimSpace(overlay.Alias)
}
label := tmMapStr(prop, "title")
if label == "" {
label = tmFirstLine(tmMapStr(prop, "description"))
}
entry.Params[name] = toolMappingParam{
Flag: flag,
Label: label,
Type: tmMapStr(prop, "type"),
}
}
if len(entry.Params) == 0 {
entry.Params = nil
}
mapping.Tools[tool.RPCName] = entry
}
}
mapping.Count = len(mapping.Tools)
return mapping
}
// tmBuildCLICommand 拼出 CLI 命令路径,如 chat + message + list -> "chat message list"。
func tmBuildCLICommand(command string, tool ir.ToolDescriptor) string {
parts := make([]string, 0, 3)
if command != "" {
parts = append(parts, command)
}
if g := strings.TrimSpace(tool.Group); g != "" {
parts = append(parts, g)
}
name := strings.TrimSpace(tool.CLIName)
if name == "" {
name = tool.RPCName
}
parts = append(parts, name)
return strings.Join(parts, " ")
}
func tmSchemaProperties(schema map[string]any) map[string]any {
if schema == nil {
return nil
}
props, _ := schema["properties"].(map[string]any)
return props
}
func tmMapStr(m map[string]any, key string) string {
if m == nil {
return ""
}
s, _ := m[key].(string)
return strings.TrimSpace(s)
}
// tmFirstLine 取第一句中文/换行前的片段,作为长描述的短标签兜底。
func tmFirstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexAny(s, "\n。"); i >= 0 {
return strings.TrimSpace(s[:i])
}
return s
}
func tmFirstNonEmpty(a, b string) string {
if strings.TrimSpace(a) != "" {
return strings.TrimSpace(a)
}
return strings.TrimSpace(b)
}
// tmKebab 把 camelCase 参数名转 kebab-case 作为默认 flag。
func tmKebab(s string) string {
var b strings.Builder
for i, r := range s {
if r >= 'A' && r <= 'Z' {
if i > 0 {
b.WriteByte('-')
}
b.WriteRune(r - 'A' + 'a')
continue
}
b.WriteRune(r)
}
return b.String()
}
+241 -32
View File
@@ -14,13 +14,17 @@
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"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
var (
@@ -38,6 +42,66 @@ var legacyDirectRuntimeAliases = map[string]string{
"dingtalk-ai-sincere-hire": "ai-sincere-hire",
}
const (
defaultPATProductID = "pat"
defaultPATDisplayName = "行为授权"
defaultPATServerID = "abc3c880fb90f04b52d1426aaf093766e5fc9ec38411688cbb74df42a584d374"
devappProductID = "devapp"
devappServerPath = "/server/op-app"
)
// devappMCPEndpoint resolves the open-platform app-management MCP endpoint
// from the configured gateway base URL, so it follows the active environment
// (production by default, pre when ~/.dws/mcp_url points at the pre gateway).
func devappMCPEndpoint() string {
return defaultPATGatewayBaseURL() + devappServerPath
}
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 +112,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
@@ -80,11 +145,19 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
toolEndpoints[toolName] = endpoint
}
}
for toolName := range server.CLI.ToolOverrides {
for toolName, override := range server.CLI.ToolOverrides {
toolName = strings.TrimSpace(toolName)
if toolName != "" {
toolEndpoints[toolName] = endpoint
if toolName == "" {
continue
}
// Leaves with serverOverride are routed to a different server's
// endpoint (e.g. chat's "search_my_robots" → bot). Registering
// them here would overwrite the real owner's tool → endpoint
// mapping and send the invocation to the wrong MCP URL.
if strings.TrimSpace(override.ServerOverride) != "" {
continue
}
toolEndpoints[toolName] = endpoint
}
}
}
@@ -94,6 +167,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
@@ -106,6 +220,22 @@ func shouldUseDirectRuntime(invocation executor.Invocation) bool {
}
}
// directRuntimeToolEndpoint returns the MCP endpoint owned by the server
// whose toolOverrides registered this tool name. Used to correct catalog
// lookups when two envelope servers share the same cli.command and the
// per-product endpoint map collides (see runner.go cross-check).
func directRuntimeToolEndpoint(toolName string) (string, bool) {
toolName = strings.TrimSpace(toolName)
if toolName == "" {
return "", false
}
dynamicMu.RLock()
defer dynamicMu.RUnlock()
endpoint, ok := dynamicToolEndpoints[toolName]
return endpoint, ok && strings.TrimSpace(endpoint) != ""
}
func directRuntimeEndpoint(productID, toolName string) (string, bool) {
// Priority 0: env-var override always wins (DINGTALK_<PRODUCT>_MCP_URL).
normalized := normalizeDirectRuntimeProductID(productID)
@@ -118,25 +248,93 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
}
}
dynamicMu.RLock()
de := dynamicEndpoints
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
// Hardcoded built-in: devapp is pinned to the open-platform app-management
// MCP server in source (NOT service discovery), per product decision.
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
if candidate == devappProductID {
return devappMCPEndpoint(), 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.
dynamicMu.RLock()
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
if candidate == "" {
continue
}
if de != nil {
if endpoint, ok := de[candidate]; ok {
if endpoint, ok := dynamicEndpoints[candidate]; ok {
dynamicMu.RUnlock()
return endpoint, true
}
}
// 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 != "" {
if endpoint, ok := dynamicToolEndpoints[tool]; ok {
dynamicMu.RUnlock()
return endpoint, true
}
}
dynamicMu.RUnlock()
// 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
}
}
// Priority 4: edition-owned static/supplement endpoints. Helper-only
// products such as devapp intentionally do not depend on Market discovery,
// so the internal edition may provide only an endpoint and no tool list.
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
if endpoint, ok := editionServerEndpoint(candidate); ok {
return endpoint, true
}
}
return "", false
}
func editionServerEndpoint(productID string) (string, bool) {
productID = strings.TrimSpace(productID)
if productID == "" {
return "", false
}
hooks := edition.Get()
if hooks == nil {
return "", false
}
if endpoint, ok := endpointFromEditionServers(productID, hooks.StaticServers); ok {
return endpoint, true
}
if endpoint, ok := endpointFromEditionServers(productID, hooks.SupplementServers); ok {
return endpoint, true
}
return "", false
}
func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo) (string, bool) {
if fn == nil {
return "", false
}
for _, server := range fn() {
endpoint := strings.TrimSpace(server.Endpoint)
if endpoint == "" {
continue
}
if strings.TrimSpace(server.ID) == productID {
return endpoint, true
}
for _, prefix := range server.Prefixes {
if strings.TrimSpace(prefix) == productID {
return endpoint, true
}
}
@@ -144,14 +342,18 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
return "", false
}
// DirectRuntimeProductIDs returns the set of product IDs that have direct
// runtime endpoints configured, sourced from dynamic server discovery.
// DirectRuntimeProductIDs returns product IDs that should stay visible for
// direct runtime execution. Dynamic products come from MCP discovery/plugin
// registration; built-in helper products such as devapp resolve their endpoint
// through DINGTALK_<PRODUCT>_MCP_URL instead of requiring discovery.
func DirectRuntimeProductIDs() map[string]bool {
dynamicMu.RLock()
dp := dynamicProducts
dynamicMu.RUnlock()
ids := make(map[string]bool, len(dp))
for key := range dp {
defer dynamicMu.RUnlock()
ids := make(map[string]bool, len(dynamicProducts)+2)
ids[defaultPATProductID] = true
ids[devappProductID] = true
for key := range dynamicProducts {
ids[key] = true
}
return ids
@@ -190,7 +392,9 @@ func AppendDynamicServer(server market.ServerDescriptor) {
}
cmd := strings.TrimSpace(server.CLI.Command)
if cmd != "" && cmd != id && endpoint != "" {
dynamicEndpoints[cmd] = endpoint
if _, exists := dynamicEndpoints[cmd]; !exists {
dynamicEndpoints[cmd] = endpoint
}
dynamicProducts[cmd] = true
}
for _, alias := range server.CLI.Aliases {
@@ -208,25 +412,30 @@ func AppendDynamicServer(server market.ServerDescriptor) {
dynamicToolEndpoints[toolName] = endpoint
}
}
for toolName := range server.CLI.ToolOverrides {
for toolName, override := range server.CLI.ToolOverrides {
toolName = strings.TrimSpace(toolName)
if toolName != "" {
dynamicToolEndpoints[toolName] = endpoint
if toolName == "" {
continue
}
// Leaves with serverOverride are routed to a different server's
// endpoint; skip to avoid overwriting the real owner's mapping.
if strings.TrimSpace(override.ServerOverride) != "" {
continue
}
dynamicToolEndpoints[toolName] = endpoint
}
}
}
func normalizeDirectRuntimeProductID(productID string) string {
dynamicMu.RLock()
da := dynamicAliases
dynamicMu.RUnlock()
trimmed := strings.TrimSpace(productID)
if da != nil {
if normalizedID, ok := da[trimmed]; ok && normalizedID != "" {
return normalizedID
}
dynamicMu.RLock()
if normalizedID, ok := dynamicAliases[trimmed]; ok && normalizedID != "" {
dynamicMu.RUnlock()
return normalizedID
}
dynamicMu.RUnlock()
if normalizedID, ok := legacyDirectRuntimeAliases[trimmed]; ok {
return normalizedID
}
@@ -0,0 +1,356 @@
// 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"
)
// Regression for the chat/bot tool routing bug: when the `chat` envelope
// declares toolOverrides with `serverOverride: "bot"` (e.g. `search_my_robots`,
// `send_message_by_custom_robot`), those tool names must NOT be registered
// into `dynamicToolEndpoints` pointing at chat's endpoint. Otherwise the
// tool-level Priority 1 lookup in `directRuntimeEndpoint` returns chat's URL
// even when the invocation's CanonicalProduct is "bot", causing the Portal to
// respond with `PARAM_ERROR - 未找到指定工具` because chat's mcpId has no such
// tool.
//
// Owner (bot envelope) still registers the tool (no serverOverride on the bot
// side), so product-level and tool-level lookups both resolve correctly.
const (
testBotEndpoint = "https://pre-mcp-gw.dingtalk.com/server/4717d5cbb92ecdebd89c174e4331dc17207208a97622e2004cac49c0fbedc9d1"
testChatEndpoint = "https://pre-mcp-gw.dingtalk.com/server/0a1609437385696b77fc4771c3ddaf5656b487f809966c0cc8d4755e7b1d3b74"
)
// botDescriptor returns a minimal `bot` server descriptor that owns the
// `search_my_robots` + `send_message_by_custom_robot` tools (no
// serverOverride — bot is the real owner).
func botDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testBotEndpoint,
CLI: market.CLIOverlay{
ID: "bot",
ToolOverrides: map[string]market.CLIToolOverride{
"search_my_robots": {CLIName: "search"},
"send_message_by_custom_robot": {CLIName: "send-by-webhook"},
"add_robot_to_group": {CLIName: "add-bot"},
},
},
}
}
// chatDescriptor returns a minimal `chat` server descriptor whose
// toolOverrides include bot-owned tools via `serverOverride: "bot"`, plus a
// chat-native tool (`search_groups_by_keyword`) that must remain routed to
// chat's endpoint.
func chatDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testChatEndpoint,
CLI: market.CLIOverlay{
ID: "chat",
Command: "chat",
ToolOverrides: map[string]market.CLIToolOverride{
"search_groups_by_keyword": {CLIName: "search"},
"search_my_robots": {
CLIName: "search",
ServerOverride: "bot",
},
"send_message_by_custom_robot": {
CLIName: "send-by-webhook",
ServerOverride: "bot",
},
"add_robot_to_group": {
CLIName: "add-bot",
ServerOverride: "bot",
},
},
},
}
}
// withCleanDynamicRegistry snapshots and restores the package-level dynamic
// registries so parallel/other tests aren't affected by this case's mutations.
func withCleanDynamicRegistry(t *testing.T) {
t.Helper()
dynamicMu.Lock()
prev := struct {
endpoints map[string]string
products map[string]bool
aliases map[string]string
toolEndpoints map[string]string
}{dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints}
dynamicEndpoints = nil
dynamicProducts = nil
dynamicAliases = nil
dynamicToolEndpoints = nil
dynamicMu.Unlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicEndpoints = prev.endpoints
dynamicProducts = prev.products
dynamicAliases = prev.aliases
dynamicToolEndpoints = prev.toolEndpoints
dynamicMu.Unlock()
})
}
func assertEndpoint(t *testing.T, productID, toolName, want string) {
t.Helper()
got, ok := directRuntimeEndpoint(productID, toolName)
if !ok {
t.Fatalf("directRuntimeEndpoint(%q, %q) returned ok=false", productID, toolName)
}
if got != want {
t.Fatalf("directRuntimeEndpoint(%q, %q) = %q, want %q", productID, toolName, got, want)
}
}
// TestSetDynamicServers_ServerOverrideDoesNotHijackToolEndpoint verifies that
// chat's serverOverride entries cannot steal bot-owned tool routes, regardless
// of registration order.
func TestSetDynamicServers_ServerOverrideDoesNotHijackToolEndpoint(t *testing.T) {
tests := []struct {
name string
servers []market.ServerDescriptor
}{
{
name: "bot first, chat second",
servers: []market.ServerDescriptor{botDescriptor(), chatDescriptor()},
},
{
name: "chat first, bot second",
servers: []market.ServerDescriptor{chatDescriptor(), botDescriptor()},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
withCleanDynamicRegistry(t)
SetDynamicServers(tc.servers)
// Bot-owned tools must route to bot's endpoint even though chat
// declares toolOverrides for them (with serverOverride="bot").
assertEndpoint(t, "bot", "search_my_robots", testBotEndpoint)
assertEndpoint(t, "bot", "send_message_by_custom_robot", testBotEndpoint)
assertEndpoint(t, "bot", "add_robot_to_group", testBotEndpoint)
// Chat-native tools must still route to chat.
assertEndpoint(t, "chat", "search_groups_by_keyword", testChatEndpoint)
// Product-level fallback for bot (no tool name) must also return
// bot's endpoint.
assertEndpoint(t, "bot", "", testBotEndpoint)
})
}
}
// TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint exercises
// the plugin-injection path (`AppendDynamicServer`) which has the same
// `toolOverrides` registration loop as `SetDynamicServers`. Chat's
// serverOverride entries must not overwrite bot's tool → endpoint mapping.
func TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint(t *testing.T) {
orders := [][]market.ServerDescriptor{
{botDescriptor(), chatDescriptor()},
{chatDescriptor(), botDescriptor()},
}
for _, servers := range orders {
t.Run("", func(t *testing.T) {
withCleanDynamicRegistry(t)
for _, s := range servers {
AppendDynamicServer(s)
}
assertEndpoint(t, "bot", "search_my_robots", testBotEndpoint)
assertEndpoint(t, "bot", "send_message_by_custom_robot", testBotEndpoint)
assertEndpoint(t, "chat", "search_groups_by_keyword", testChatEndpoint)
})
}
}
// --- 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)
})
}
}
// --- Command field first-writer-wins regression test ---
//
// When two plugins declare the same CLI.Command but different CLI.ID values,
// AppendDynamicServer must NOT let the second registration overwrite the
// command → endpoint mapping established by the first. The fix uses a simple
// "if not exists" guard on dynamicEndpoints[cmd].
const (
testFirstEndpoint = "https://mcp-gw.dingtalk.com/server/first-plugin-hash"
testSecondEndpoint = "https://mcp-gw.dingtalk.com/server/second-plugin-hash"
)
func firstPluginDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testFirstEndpoint,
CLI: market.CLIOverlay{
ID: "plugin-alpha",
Command: "shared-cmd",
},
}
}
func secondPluginDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testSecondEndpoint,
CLI: market.CLIOverlay{
ID: "plugin-beta",
Command: "shared-cmd",
},
}
}
// TestAppendDynamicServer_CommandEndpointFirstWriterWins verifies that when
// two plugins declare the same Command (but different IDs), only the first
// registration takes effect for the command → endpoint mapping. The second
// plugin's own id-based endpoint is unaffected.
func TestAppendDynamicServer_CommandEndpointFirstWriterWins(t *testing.T) {
withCleanDynamicRegistry(t)
AppendDynamicServer(firstPluginDescriptor())
AppendDynamicServer(secondPluginDescriptor())
// The command "shared-cmd" must resolve to the first plugin's endpoint.
assertEndpoint(t, "shared-cmd", "", testFirstEndpoint)
// Each plugin's own id-based endpoint is always unconditionally written.
assertEndpoint(t, "plugin-alpha", "", testFirstEndpoint)
assertEndpoint(t, "plugin-beta", "", testSecondEndpoint)
// Command must appear in dynamicProducts (discovery) regardless.
ids := DirectRuntimeProductIDs()
if !ids["shared-cmd"] {
t.Fatal("shared-cmd not found in DirectRuntimeProductIDs()")
}
if !ids["plugin-alpha"] {
t.Fatal("plugin-alpha not found in DirectRuntimeProductIDs()")
}
if !ids["plugin-beta"] {
t.Fatal("plugin-beta not found in DirectRuntimeProductIDs()")
}
}
// 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)
}
+171 -1
View File
@@ -1,6 +1,176 @@
package app
import "testing"
import (
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
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 TestDirectRuntimeProductIDsIncludesDevappHelper(t *testing.T) {
withCleanDynamicRegistry(t)
ids := DirectRuntimeProductIDs()
if !ids["devapp"] {
t.Fatalf("DirectRuntimeProductIDs() missing devapp helper product: %#v", ids)
}
}
func TestDirectRuntimeEndpoint_DevappEnvOverrideWithoutRegistry(t *testing.T) {
withCleanDynamicRegistry(t)
t.Setenv("DINGTALK_DEVAPP_MCP_URL", "https://example.test/server/devapp")
assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp")
}
func TestDirectRuntimeEndpoint_DevappEnvOverridePreservesQuery(t *testing.T) {
withCleanDynamicRegistry(t)
t.Setenv("DINGTALK_DEVAPP_MCP_URL", "https://example.test/server/devapp?key=secret")
assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp?key=secret")
}
func TestDirectRuntimeEndpoint_DevappDynamicServerDoesNotOverrideHardcoded(t *testing.T) {
withCleanDynamicRegistry(t)
SetDynamicServers([]market.ServerDescriptor{
{
Endpoint: "https://example.test/server/devapp-supplement",
CLI: market.CLIOverlay{
ID: "devapp",
Command: "devapp",
},
},
})
assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint())
}
func TestDirectRuntimeEndpoint_DevappEditionSupplementDoesNotOverrideHardcoded(t *testing.T) {
withCleanDynamicRegistry(t)
prev := edition.Get()
edition.Override(&edition.Hooks{
Name: "wukong",
SupplementServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{
ID: "devapp",
Name: "开放平台应用管理",
Endpoint: "https://example.test/server/devapp-edition-supplement?key=secret",
Prefixes: []string{"devapp", "app"},
},
}
},
})
t.Cleanup(func() { edition.Override(prev) })
assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint())
}
func TestDirectRuntimeEndpoint_DevappEditionStaticDoesNotOverrideHardcoded(t *testing.T) {
withCleanDynamicRegistry(t)
prev := edition.Get()
edition.Override(&edition.Hooks{
Name: "wukong",
StaticServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{
ID: "devapp",
Name: "开放平台应用管理",
Endpoint: "https://example.test/server/devapp-edition-static",
Prefixes: []string{"devapp", "app"},
},
}
},
})
t.Cleanup(func() { edition.Override(prev) })
assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint())
}
func TestDirectRuntimeEndpoint_DevappEnvOverrideWinsOverEditionSupplement(t *testing.T) {
withCleanDynamicRegistry(t)
t.Setenv("DINGTALK_DEVAPP_MCP_URL", "https://example.test/server/devapp-env")
prev := edition.Get()
edition.Override(&edition.Hooks{
Name: "wukong",
SupplementServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{
ID: "devapp",
Name: "开放平台应用管理",
Endpoint: "https://example.test/server/devapp-edition-supplement",
},
}
},
})
t.Cleanup(func() { edition.Override(prev) })
assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp-env")
}
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()
+138
View File
@@ -0,0 +1,138 @@
// 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"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
const (
docProductID = "doc"
docDownloadFileTool = "download_file"
docGetDocumentInfoTool = "get_document_info"
docAXLSExtension = "axls"
)
func (r *runtimeRunner) preflightDocDownload(ctx context.Context, tc *transport.Client, endpoint string, invocation executor.Invocation) error {
if !isDocDownloadInvocation(invocation) {
return nil
}
nodeID := docDownloadNodeID(invocation.Params)
if nodeID == "" {
return nil
}
preflightStart := time.Now()
info, err := tc.CallTool(ctx, endpoint, docGetDocumentInfoTool, map[string]any{"nodeId": nodeID})
RecordTiming(ctx, "doc_download_preflight", time.Since(preflightStart))
if err != nil {
return err
}
if classify := edition.Get().ClassifyToolResult; classify != nil {
if err := classify(info.Content); err != nil {
return err
}
}
if patCheck := apperrors.ClassifyPatAuthCheck(info.Content); patCheck != nil {
return patCheck
}
if info.IsError {
return apperrors.NewAPI(
extractMCPErrorMessage(info),
apperrors.WithOperation("doc.get_document_info"),
apperrors.WithReason("doc_download_preflight_failed"),
apperrors.WithServerKey(docProductID),
apperrors.WithHint("doc download 必须先确认节点类型,避免对不支持下载的在线表格触发 drive:download 授权。"),
apperrors.WithActions("dws doc info --node <nodeId>"),
)
}
if bizErr := detectBusinessError(info.Content); bizErr != "" {
return apperrors.NewAPI(
bizErr,
apperrors.WithOperation("doc.get_document_info"),
apperrors.WithReason("doc_download_preflight_failed"),
apperrors.WithServerKey(docProductID),
apperrors.WithHint("doc download 必须先确认节点类型,避免对不支持下载的在线表格触发 drive:download 授权。"),
apperrors.WithActions("dws doc info --node <nodeId>"),
)
}
if strings.EqualFold(documentInfoExtension(info.Content), docAXLSExtension) {
return unsupportedAXLSDownloadError()
}
return nil
}
func isDocDownloadInvocation(invocation executor.Invocation) bool {
return strings.EqualFold(strings.TrimSpace(invocation.CanonicalProduct), docProductID) &&
strings.TrimSpace(invocation.Tool) == docDownloadFileTool
}
func docDownloadNodeID(params map[string]any) string {
for _, key := range []string{"nodeId", "node", "dentryUuid"} {
if value, ok := params[key].(string); ok {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
}
return ""
}
func unsupportedAXLSDownloadError() error {
return apperrors.NewValidation(
"nodeId 指向的节点是钉钉表格(extension=axls),在线表格不支持直接下载。请使用 getRange 工具获取表格数据。",
apperrors.WithOperation("doc.download_file.preflight"),
apperrors.WithReason("unsupported_alidoc_extension"),
apperrors.WithServerKey(docProductID),
apperrors.WithHint("在线表格应先用 doc info 确认 extension,再改用表格 MCP 的 get_all_sheets / get_range 读取数据。"),
apperrors.WithActions("dws doc info --node <nodeId>", "使用表格 MCP get_all_sheets / get_range"),
)
}
func documentInfoExtension(content map[string]any) string {
for _, path := range [][]string{
{"result", "extension"},
{"data", "extension"},
{"extension"},
} {
if value := stringAtPath(content, path...); value != "" {
return value
}
}
return ""
}
func stringAtPath(value any, path ...string) string {
current := value
for _, key := range path {
object, ok := current.(map[string]any)
if !ok {
return ""
}
current = object[key]
}
if text, ok := current.(string); ok {
return strings.TrimSpace(text)
}
return ""
}
+22 -19
View File
@@ -22,9 +22,9 @@ import (
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -113,7 +113,8 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
return output.WriteJSON(w, result)
}
fmt.Fprintf(w, "\n诊断完成: %d 项通过, %d 项警告, %d 项失败\n", pass, warn, fail)
fmt.Fprintf(w, "\n%s\n", tui.Header("Doctor", fmt.Sprintf("%d pass · %d warn · %d fail", pass, warn, fail)))
fmt.Fprintf(w, "%s 诊断完成: %d 项通过, %d 项警告, %d 项失败\n", tui.StateMark("ok"), pass, warn, fail)
if fail > 0 {
return fmt.Errorf("诊断发现 %d 项失败", fail)
}
@@ -124,7 +125,7 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult {
if !jsonOut {
fmt.Fprint(w, "检查登录状态... ")
fmt.Fprint(w, tui.Dim("检查登录状态... "))
}
configDir := defaultConfigDir()
@@ -187,10 +188,10 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
if !jsonOut {
fmt.Fprint(w, "检查网络连通性... ")
fmt.Fprint(w, tui.Dim("检查网络连通性... "))
}
baseURL := cli.DefaultMarketBaseURL
baseURL := config.GetMCPBaseURL()
httpClient := &http.Client{Timeout: timeout}
client := market.NewClient(baseURL, httpClient)
@@ -205,7 +206,7 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
r := checkResult{
Name: "network",
Status: statusFail,
Message: fmt.Sprintf("mcp.dingtalk.com 不可达: %v", err),
Message: fmt.Sprintf("%s 不可达: %v", baseURL, err),
Hint: "请检查网络连接或代理设置",
}
if !jsonOut {
@@ -217,7 +218,7 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
r := checkResult{
Name: "network",
Status: statusPass,
Message: fmt.Sprintf("mcp.dingtalk.com 可达 (延迟 %dms)", latency.Milliseconds()),
Message: fmt.Sprintf("%s 可达 (延迟 %dms)", baseURL, latency.Milliseconds()),
}
if !jsonOut {
printCheckResult(w, r)
@@ -229,7 +230,7 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
if !jsonOut {
fmt.Fprint(w, "检查缓存状态... ")
fmt.Fprint(w, tui.Dim("检查缓存状态... "))
}
store := cacheStoreFromEnv()
@@ -301,7 +302,7 @@ func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
if !jsonOut {
fmt.Fprint(w, "检查版本更新... ")
fmt.Fprint(w, tui.Dim("检查版本更新... "))
}
currentVer := version
@@ -349,9 +350,18 @@ func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkR
func printCheckResult(w io.Writer, r checkResult) {
icon := statusIcon(r.Status)
fmt.Fprintf(w, "%s %s\n", icon, r.Message)
message := r.Message
switch r.Status {
case statusPass:
message = tui.Success(message)
case statusWarn:
message = tui.Warning(message)
case statusFail:
message = tui.Danger(message)
}
fmt.Fprintf(w, "%s %s\n", icon, message)
if r.Hint != "" {
fmt.Fprintf(w, " %s\n", r.Hint)
fmt.Fprintf(w, " %s\n", tui.Dim(r.Hint))
}
}
@@ -386,7 +396,7 @@ func countResults(checks []checkResult) (pass, warn, fail int) {
func doctorCheckPerf(w io.Writer, jsonOut bool) checkResult {
if !jsonOut {
fmt.Fprint(w, "检查性能报告... ")
fmt.Fprint(w, tui.Dim("检查性能报告... "))
}
report, err := LoadLatestReport()
@@ -429,10 +439,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")
}
+105
View File
@@ -0,0 +1,105 @@
// 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 (
stderrors "errors"
"fmt"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
func TestFlagErrorWithSuggestions_authStructured(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "login", Run: func(*cobra.Command, []string) {}}
orig := fmt.Errorf("unknown flag: --json")
err := flagErrorWithSuggestions(cmd, orig)
var ae *apperrors.Error
if !stderrors.As(err, &ae) {
t.Fatalf("want *apperrors.Error, got %T", err)
}
if !strings.Contains(ae.Message, orig.Error()) {
t.Fatalf("Message = %q, want to contain %q", ae.Message, orig.Error())
}
// 尾部 hint:所有 flag 解析错误的 Message 都应以 See '<cmd> --help' for usage. 结尾
if !strings.HasSuffix(ae.Message, "See 'login --help' for usage.") {
t.Fatalf("Message tail = %q, want suffix See 'login --help' for usage.", ae.Message)
}
if ae.Reason != "unknown_flag" {
t.Fatalf("Reason = %q, want unknown_flag", ae.Reason)
}
if ae.Hint == "" || !strings.Contains(ae.Hint, "format json") {
t.Fatalf("Hint = %q", ae.Hint)
}
if ae.Cause != orig {
t.Fatalf("Cause = %v, want orig", ae.Cause)
}
if !stderrors.Is(err, orig) {
t.Fatal("errors.Is(err, orig) should hold via unwrap")
}
}
func TestFlagErrorWithSuggestions_unknownFlagHintAndFlags(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
cmd.Flags().String("start", "", "begin time")
_ = cmd.Flags().SetAnnotation("start", "x-cli-format", []string{"date-time"})
orig := fmt.Errorf("unknown flag: --starttime1")
err := flagErrorWithSuggestions(cmd, orig)
var ae *apperrors.Error
if !stderrors.As(err, &ae) {
t.Fatalf("want *apperrors.Error, got %T", err)
}
if ae.Reason != "unknown_flag" {
t.Fatalf("Reason = %q", ae.Reason)
}
if strings.Contains(ae.Hint, "Space required") {
t.Fatalf("false glue must not suggest space: %q", ae.Hint)
}
if !strings.Contains(ae.Hint, "help") {
t.Fatalf("expected help fallback in hint, got %q", ae.Hint)
}
if len(ae.AvailableFlags) != 1 || ae.AvailableFlags[0] != "start" {
t.Fatalf("AvailableFlags = %v, want [start]", ae.AvailableFlags)
}
// 尾部 hint 验证:非 alias 路径(SuggestFlagFix 命中)同样应带 See '... --help' for usage.
if !strings.HasSuffix(ae.Message, "See 'list --help' for usage.") {
t.Fatalf("Message tail = %q, want suffix See 'list --help' for usage.", ae.Message)
}
}
// TestFlagErrorWithSuggestions_fallbackTailHint 验证 fallback 路径(非 unknown flag 类错误,
// 如 missing required flag / ambiguous shorthand)也带尾部 See '<cmd> --help' for usage.
// 这是 wukong / docker / kubectl 的通用 UX——任何 flag 解析错误都给用户一条 help 入口。
func TestFlagErrorWithSuggestions_fallbackTailHint(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}}
orig := fmt.Errorf("required flag(s) \"to\" not set")
err := flagErrorWithSuggestions(cmd, orig)
// fallback 路径返回 plain error(非 *apperrors.Error),保持原 exit code 行为
var ae *apperrors.Error
if stderrors.As(err, &ae) {
t.Fatalf("fallback path should return plain error, got *apperrors.Error: %v", err)
}
msg := err.Error()
if !strings.Contains(msg, orig.Error()) {
t.Fatalf("err = %q, want to contain orig %q", msg, orig.Error())
}
if !strings.HasSuffix(msg, "See 'send --help' for usage.") {
t.Fatalf("err tail = %q, want suffix See 'send --help' for usage.", msg)
}
}
+3 -1
View File
@@ -29,6 +29,7 @@ type GlobalFlags struct {
JQ string
Mock bool
Output string
Profile string
Timeout int
Token string
Verbose bool
@@ -41,11 +42,12 @@ func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) {
cmd.PersistentFlags().BoolVar(&flags.Debug, "debug", false, "显示调试日志")
cmd.PersistentFlags().BoolVar(&flags.DryRun, "dry-run", false, "预览操作内容,不实际执行")
cmd.PersistentFlags().StringVar(&flags.Fields, "fields", "", "筛选输出字段 (逗号分隔, 如: name,id,status)")
cmd.PersistentFlags().StringVarP(&flags.Format, "format", "f", "json", "输出格式: json|table|raw")
cmd.PersistentFlags().StringVarP(&flags.Format, "format", "f", "json", "输出格式: json|table|raw|pretty|ndjson|csv")
cmd.PersistentFlags().StringVar(&flags.JQ, "jq", "", "jq 表达式过滤输出 (如: '.items[] | .name')")
cmd.PersistentFlags().BoolVar(&flags.Mock, "mock", false, "使用 Mock 数据 (开发调试用)")
cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "", "Write command output to a file")
_ = cmd.PersistentFlags().MarkHidden("output")
cmd.PersistentFlags().StringVar(&flags.Profile, "profile", "", "一次性指定本次命令使用的组织 profile 名或 corpId;多个按 CSV 逗号分隔,如 corpA,corpB")
cmd.PersistentFlags().IntVar(&flags.Timeout, "timeout", 30, "HTTP 请求超时时间 (秒)")
cmd.PersistentFlags().StringVar(&flags.Token, "token", "", "Override the configured API token")
_ = cmd.PersistentFlags().MarkHidden("token")
+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 (
"context"
"fmt"
"io"
"log/slog"
"strings"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
)
// ForceRefreshAccessToken forces a single refresh_token exchange and returns
// the new access_token. It is intended for callers that have observed a
// server-side rejection (HTTP 401 or business code such as
// TOKEN_VERIFIED_FAILED) on what locally appeared to be a still-valid token.
//
// Steps:
// 1. MarkAccessTokenStale rewrites ExpiresAt to a past instant so
// OAuthProvider.GetAccessToken's fast-path will miss.
// 2. NewOAuthProvider + GetAccessToken triggers lockedRefresh, which uses the
// existing dual-layer lock (process + file) to serialize concurrent
// refresh attempts across goroutines and processes.
// 3. ResetRuntimeTokenCache clears the per-process sync.Once cache so the
// next resolveAuthToken call re-reads from disk.
//
// Existing OAuthProvider.GetAccessToken behaviour is unchanged; this helper
// is the only entry point that orchestrates "force refresh" semantics.
func ForceRefreshAccessToken(ctx context.Context, configDir string) (string, error) {
if strings.TrimSpace(configDir) == "" {
return "", fmt.Errorf("config directory is empty")
}
if err := authpkg.MarkAccessTokenStale(configDir); err != nil {
return "", fmt.Errorf("mark access token stale: %w", err)
}
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
provider := authpkg.NewOAuthProvider(configDir, disc)
configureOAuthProviderCompatibility(provider, configDir)
tok, err := provider.GetAccessToken(ctx)
if err != nil {
return "", err
}
tok = strings.TrimSpace(tok)
if tok == "" {
return "", fmt.Errorf("force refresh returned empty access token")
}
ResetRuntimeTokenCache()
return tok, nil
}
+79 -3
View File
@@ -55,10 +55,11 @@ func TestRootCommandDoesNotInjectPatchedHelpCommands(t *testing.T) {
t.Cleanup(func() { SetDiscoveryBaseURL("") })
root := NewRootCommand()
// `minutes list all` is intentionally provided as a hardcoded helper
// (see internal/helpers/minutes_commands.go) to align with the wukong
// baseline, so it is expected to resolve and is no longer asserted here.
for _, path := range []string{
"doc upload",
"chat message list-topic-replies",
"minutes list all",
} {
if cmd := lookupCommand(root, path); cmd != nil {
t.Fatalf("findCommand(%q) = %q, want nil", path, cmd.CommandPath())
@@ -162,11 +163,16 @@ func TestRootHelpUsesMCPOnlySummary(t *testing.T) {
t.Fatalf("root help missing %q:\n%s", want, got)
}
}
for _, unwanted := range []string{"快速开始:", "更多信息:", "auth 认证管理", "Flags:"} {
for _, unwanted := range []string{"快速开始:", "更多信息:", "auth 认证管理"} {
if strings.Contains(got, unwanted) {
t.Fatalf("root help unexpectedly contains %q:\n%s", unwanted, got)
}
}
for _, want := range []string{"Global Flags:", "--profile"} {
if !strings.Contains(got, want) {
t.Fatalf("root help missing %q:\n%s", want, got)
}
}
}
func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) {
@@ -216,6 +222,60 @@ func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) {
}
}
func TestProfileHelpDocumentsMultiProfileUsage(t *testing.T) {
got := executeHelpForTest(t, "profile", "switch", "--help")
for _, want := range []string{
"切换默认组织 profile",
"需要只影响单次业务命令时,请使用全局 --profile",
"dws profile switch --corpId <corpId>",
"dws --profile <corpId> contact user get-self",
"--corpId string",
"--name string",
} {
if !strings.Contains(got, want) {
t.Fatalf("profile switch help missing %q:\n%s", want, got)
}
}
got = executeHelpForTest(t, "profile", "list", "--help")
for _, want := range []string{
"列出本机已登录的所有组织 profile",
"dws profile list --format json",
} {
if !strings.Contains(got, want) {
t.Fatalf("profile list help missing %q:\n%s", want, got)
}
}
}
func TestAuthHelpDocumentsProfileUsage(t *testing.T) {
got := executeHelpForTest(t, "auth", "login", "--help")
if !strings.Contains(got, "dws auth login --profile <corpId>") {
t.Fatalf("auth login help missing --profile example:\n%s", got)
}
got = executeHelpForTest(t, "auth", "status", "--help")
for _, want := range []string{
"查看当前或指定组织 profile 的认证状态",
"只读取并刷新被选中的 token slot",
"dws auth status --profile <corpId>",
} {
if !strings.Contains(got, want) {
t.Fatalf("auth status help missing %q:\n%s", want, got)
}
}
got = executeHelpForTest(t, "auth", "logout", "--help")
for _, want := range []string{
"默认退出所有已登录组织 profile",
"dws auth logout --profile <corpId>",
} {
if !strings.Contains(got, want) {
t.Fatalf("auth logout help missing %q:\n%s", want, got)
}
}
}
func TestRootCommandRegistersUpgradeCommand(t *testing.T) {
root := NewRootCommand()
if cmd := lookupCommand(root, "upgrade"); cmd == nil {
@@ -223,6 +283,22 @@ func TestRootCommandRegistersUpgradeCommand(t *testing.T) {
}
}
func executeHelpForTest(t *testing.T, args ...string) string {
t.Helper()
t.Setenv(cli.CatalogFixtureEnv, "")
t.Setenv(cli.CacheDirEnv, t.TempDir())
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatalf("Execute(%v) error = %v\noutput:\n%s", args, err, out.String())
}
return out.String()
}
func discoveryServerEntry(command, description string, groups, toolOverrides map[string]any) map[string]any {
cliMeta := map[string]any{
"id": command,
+136
View File
@@ -0,0 +1,136 @@
// 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"
"fmt"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
// newHelperToolFetcher returns a cli.HelperToolFetcher that loads a helper MCP
// server's tools/list LIVE (by source) and projects each tool into a
// cli.HelperToolSchema (name, description, inputSchema properties/required). It
// is injected into the schema command so the cli package can render
// `dws schema dev.*` from real server schema without importing app/transport.
//
// Sources: "op-app" backs the dev app commands (pinned endpoint); "devdoc"
// backs `dws dev doc search` (endpoint resolved dynamically, see
// helperSourceEndpoint). Results are memoized per source per process so
// repeated `dws schema dev.*` hit the network at most once per source. A failed
// fetch is not cached, allowing a later retry within the same process.
func newHelperToolFetcher() cli.HelperToolFetcher {
var (
mu sync.Mutex
cached = map[string]map[string]cli.HelperToolSchema{}
)
return func(ctx context.Context, source string) (map[string]cli.HelperToolSchema, error) {
mu.Lock()
if got, ok := cached[source]; ok {
mu.Unlock()
return got, nil
}
mu.Unlock()
endpoint, err := helperSourceEndpoint(source)
if err != nil {
return nil, err
}
schemas, err := fetchHelperToolSchemas(ctx, endpoint)
if err != nil {
return nil, err
}
mu.Lock()
cached[source] = schemas
mu.Unlock()
return schemas, nil
}
}
// helperSourceEndpoint maps a schema source to its MCP endpoint. op-app (dev
// app) is pinned in source (devappMCPEndpoint, derived from the active gateway
// base — production by default, pre when ~/.dws/mcp_url points at pre); other
// sources (e.g. devdoc) are resolved the same way the runner resolves a product
// endpoint — env override → discovery → edition StaticServers/SupplementServers.
func helperSourceEndpoint(source string) (string, error) {
switch source {
case "", "op-app", "devapp":
return devappMCPEndpoint(), nil
default:
if endpoint, ok := directRuntimeEndpoint(source, ""); ok {
return endpoint, nil
}
return "", fmt.Errorf("no MCP endpoint resolved for source %q (not injected by edition/discovery)", source)
}
}
// fetchHelperToolSchemas performs the live tools/list call against endpoint and
// converts the descriptors. Auth and identity headers are resolved the same way
// the runner does for direct-runtime invocations.
func fetchHelperToolSchemas(ctx context.Context, endpoint string) (map[string]cli.HelperToolSchema, error) {
token := resolveRuntimeAuthToken(ctx, "")
headers := resolveIdentityHeaders()
client := transport.NewClient(nil).WithAuth(token, headers)
result, err := client.ListTools(ctx, endpoint)
if err != nil {
return nil, err
}
out := make(map[string]cli.HelperToolSchema, len(result.Tools))
for _, td := range result.Tools {
out[td.Name] = cli.HelperToolSchema{
Name: td.Name,
Description: td.Description,
Properties: inputSchemaProperties(td.InputSchema),
Required: inputSchemaRequired(td.InputSchema),
}
}
return out, nil
}
// inputSchemaProperties pulls the "properties" object out of a deserialized
// MCP inputSchema map. Returns an empty (non-nil) map when absent.
func inputSchemaProperties(schema map[string]any) map[string]any {
if schema == nil {
return map[string]any{}
}
props, _ := schema["properties"].(map[string]any)
if props == nil {
return map[string]any{}
}
return props
}
// inputSchemaRequired pulls the "required" string list out of a deserialized
// MCP inputSchema map.
func inputSchemaRequired(schema map[string]any) []string {
if schema == nil {
return nil
}
raw, ok := schema["required"].([]any)
if !ok {
return nil
}
out := make([]string, 0, len(raw))
for _, v := range raw {
if s, ok := v.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}
+300 -26
View File
@@ -16,6 +16,7 @@ package app
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
@@ -29,9 +30,11 @@ 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"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
@@ -47,12 +50,121 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
return mergeTopLevelCommands(commands)
}
var commands []*cobra.Command
if dynamicCmds := loadDynamicCommands(ctx, runner); len(dynamicCmds) > 0 {
commands = append(commands, dynamicCmds...)
return buildEnvelopeCommandsSafe(ctx, runner)
}
// loadDynamicCommandsFn is a test seam for buildEnvelopeCommandsSafe so a
// panic in the cache-driven build can be simulated without crafting a
// poisoned on-disk cache.
var loadDynamicCommandsFn = loadDynamicCommands
// buildEnvelopeCommandsSafe builds the public command set from the discovery
// envelope, self-healing a poisoned cache when the dynamic build panics and
// degrading to the hardcoded helper commands only if that also fails.
//
// Why this guard exists: the dynamic command tree is constructed from cached
// discovery data BEFORE Cobra dispatches any command, so a panic here (e.g.
// a duplicate pflag registration fed by a poisoned cache, as seen before
// 1.0.32: "chat_permission_grant flag redefined: params") used to abort
// every invocation — including `dws cache refresh`, the very command that
// repairs the cache.
//
// Recovery is two-staged. First the partition's discovery cache is moved
// aside (kept on disk for inspection) and the build retried against a fresh
// fetch — so any path that delivers a fixed binary (`dws upgrade`, reinstall)
// escapes the lock-out with zero manual cache surgery. Only when the rebuild
// panics again (e.g. the remote envelope itself is still poisoned, or the
// machine is offline with no usable cache) does the CLI degrade to utility
// and helper commands with a `dws cache refresh` hint.
func buildEnvelopeCommandsSafe(ctx context.Context, runner executor.Runner) []*cobra.Command {
cmds, panicked := tryBuildEnvelopeCommands(ctx, runner)
if panicked == nil {
return cmds
}
commands = append(commands, helpers.NewPublicCommands(runner)...)
return mergeTopLevelCommands(commands)
slog.Error("buildEnvelopeCommandsSafe: dynamic command build panicked", "panic", panicked)
quarantined, qErr := cacheStoreFromEnv().QuarantinePartition(editionPartition())
if qErr != nil {
slog.Error("buildEnvelopeCommandsSafe: failed to quarantine discovery cache", "error", qErr)
}
if quarantined != "" {
fmt.Fprintf(os.Stderr,
"Warning: building product commands from the local discovery cache failed: %v\n"+
"The cached discovery data was moved to %s; rebuilding from a fresh fetch...\n",
panicked, quarantined)
cmds, panicked = tryBuildEnvelopeCommands(ctx, runner)
if panicked == nil {
fmt.Fprintln(os.Stderr, "Product commands rebuilt successfully.")
return cmds
}
slog.Error("buildEnvelopeCommandsSafe: rebuild after cache quarantine panicked again, degrading to built-in commands", "panic", panicked)
}
fmt.Fprintf(os.Stderr,
"Warning: building product commands from the local discovery cache failed: %v\n"+
"Product commands are temporarily unavailable; utility commands still work.\n"+
"Run 'dws cache refresh' to rebuild the cache.\n", panicked)
return mergeTopLevelCommands(helpers.NewPublicCommands(runner))
}
// tryBuildEnvelopeCommands runs one attempt of the envelope-driven build,
// converting a panic into a return value so the caller can decide between
// self-heal and degradation.
func tryBuildEnvelopeCommands(ctx context.Context, runner executor.Runner) (cmds []*cobra.Command, panicked any) {
defer func() {
if r := recover(); r != nil {
cmds = nil
panicked = r
}
}()
dynamicCmds := loadDynamicCommandsFn(ctx, runner)
helperCmds := helpers.NewPublicCommands(runner)
merged := mergeTopLevelCommands(pickCommands(dynamicCmds, helperCmds))
// Post-merge product hooks: tasks the envelope cannot express on its
// own (e.g. dual-role group+leaf semantics for deprecated aliases).
// Keep each hook narrowly scoped to one product so the open-source
// command surface remains predictable from the envelope alone.
helpers.AttachReportLegacyInboxAlias(merged, runner)
helpers.AttachReportListReadableEnrichment(merged, runner)
return merged, nil
}
// pickCommands returns the union of dynamic and helpers commands. For
// same-named top-level products, helper-only leaves are grafted into the
// dynamic tree via cmdutil.MergeHardcodedLeaves so the discovery envelope
// remains the authority for leaves it declares, while hardcoded helpers can
// still fill gaps the envelope did not cover (e.g. `chat message send-by-bot`
// alongside the envelope's `chat message send`).
//
// Why this exists: mergeTopLevelCommands below calls cobracmd.MergeCommandTree
// on same-named top-level commands, which — at leaf conflicts — falls back to
// "more local flags wins" via ShouldReplaceLeaf. Hardcoded helpers commands
// typically expose more flags than the corresponding dynamic overlay leaves,
// so a naive append would silently promote helper leaves over their dynamic
// counterparts. MergeHardcodedLeaves avoids that by letting dynamic win every
// leaf conflict, and only adding subtrees the dynamic side lacks.
func pickCommands(dynamic, helpers []*cobra.Command) []*cobra.Command {
dynByName := make(map[string]*cobra.Command, len(dynamic))
out := make([]*cobra.Command, 0, len(dynamic)+len(helpers))
for _, c := range dynamic {
if c == nil {
continue
}
dynByName[c.Name()] = c
out = append(out, c)
}
for _, h := range helpers {
if h == nil {
continue
}
if dyn := dynByName[h.Name()]; dyn != nil {
cmdutil.MergeHardcodedLeaves(dyn, h)
continue
}
out = append(out, h)
}
return out
}
// injectStaticServers converts edition.ServerInfo entries into
@@ -87,9 +199,55 @@ 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.
// 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 {
return config.EditionPartition(edition.Get().Name)
}
// discoveryTraceEnabled reports whether the user asked for discovery-path diagnostics.
// loadDynamicCommands runs while building the command tree, before PersistentPreRun
// applies --debug to slog; we also accept argv --debug and DWS_PERF_DEBUG for consistency.
func discoveryTraceEnabled() bool {
if IsPerfDebugEnabled() {
return true
}
for _, a := range os.Args[1:] {
if a == "--debug" {
return true
}
}
return false
}
func discoveryTraceServerIDs(servers []market.ServerDescriptor) []string {
seen := make(map[string]struct{})
for _, s := range servers {
id := strings.TrimSpace(s.CLI.Command)
if id == "" {
id = strings.TrimSpace(s.CLI.ID)
}
if id == "" {
continue
}
seen[id] = struct{}{}
}
out := make([]string, 0, len(seen))
for id := range seen {
out = append(out, id)
}
sort.Strings(out)
const maxIDs = 48
if len(out) > maxIDs {
out = out[:maxIDs]
}
return out
}
func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
store := cacheStoreFromEnv()
partition := config.DefaultPartition
partition := editionPartition()
// Bypass the registry cache when a fixture override is active.
// This ensures tests that set DWS_CATALOG_FIXTURE always get fresh
@@ -116,27 +274,51 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
}
}
if len(servers) > 0 && discoveryTraceEnabled() {
slog.Info("loadDynamicCommands: skipping sync discovery fetch, using registry cache",
"partition", partition,
"servers", len(servers),
"registry_freshness", string(freshness))
}
// Cache miss or bypassed: fetch from market API synchronously (first run only).
if len(servers) == 0 {
baseURL := cli.DefaultMarketBaseURL
if discoveryBaseURLOverride != "" {
baseURL = discoveryBaseURLOverride
if discoveryTraceEnabled() {
if edURL := strings.TrimSpace(edition.Get().DiscoveryURL); edURL != "" {
slog.Info("loadDynamicCommands: sync discovery fetch", "partition", partition, "url", edURL)
} else {
slog.Info("loadDynamicCommands: sync market catalog fetch", "partition", partition, "base_url", DiscoveryBaseURL())
}
}
fetchStart := time.Now()
client := market.NewClient(baseURL, ipv4OnlyHTTPClient())
resp, fetchErr := client.FetchServers(ctx, config.DefaultFetchServersLimit)
resp, fetchErr := fetchRegistryServers(ctx, ipv4OnlyHTTPClient())
RecordTiming(ctx, "market_fetch", time.Since(fetchStart))
if fetchErr != nil {
if discoveryTraceEnabled() {
slog.Info("loadDynamicCommands: sync discovery fetch failed",
"partition", partition,
"error", fetchErr.Error())
}
slog.Debug("loadDynamicCommands: market API fetch failed", "error", fetchErr)
// Degrade to stale cache if available (production only).
if useCache && cacheErr == nil && len(snapshot.Servers) > 0 {
slog.Debug("loadDynamicCommands: degrading to stale registry cache", "servers", len(snapshot.Servers))
servers = snapshot.Servers
} else {
return nil
// no-op: fall through to FallbackServers check below
}
} else {
servers = market.NormalizeServers(resp, "market")
servers = market.NormalizeServersForBaseURL(resp, "market", registryDiscoveryBaseURL())
if discoveryTraceEnabled() {
slog.Info("loadDynamicCommands: sync discovery fetch ok",
"partition", partition,
"response_servers", len(resp.Servers),
"metadata_count", resp.Metadata.Count,
"normalized_servers", len(servers),
"cli_command_ids", discoveryTraceServerIDs(servers))
}
// Persist fresh data (only in non-test mode).
if useCache {
saveStart := time.Now()
@@ -148,23 +330,80 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
}
}
// FallbackServers: safety net when Market discovery + cache both fail.
if len(servers) == 0 {
if fn := edition.Get().FallbackServers; fn != nil {
if fb := fn(); len(fb) > 0 {
slog.Debug("loadDynamicCommands: using FallbackServers", "count", len(fb))
descriptors := editionmerge.FallbackToDescriptors(fb)
descriptors = editionmerge.MergeSupplement(descriptors)
SetDynamicServers(descriptors)
return nil
}
}
return nil
}
// Merge edition-specific supplement servers (not in Market).
servers = editionmerge.MergeSupplement(servers)
// Inject dynamic server data for endpoint resolution
SetDynamicServers(servers)
detailStart := time.Now()
detailsByID := loadCachedDetailsFast(store, servers)
existingTools := loadCachedToolNames(store, servers)
RecordTiming(ctx, "tool_metadata", time.Since(detailStart))
buildStart := time.Now()
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID)
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID, existingTools)
RecordTiming(ctx, "build_commands", time.Since(buildStart))
return cmds
}
// loadCachedToolNames reads the live tools/list snapshot from disk cache for
// each server and returns a map from CLI server ID (slug) → set of tool names
// the server actually exposes. This is the existence oracle BuildDynamicCommands
// uses to hide phantom override leaves (commands whose backing MCP tool is not
// deployed) from `--help`.
//
// Source note: this reads the `tools/` partition (populated by `dws cache
// refresh` / discovery, keyed by server.Key), NOT the `detail/` partition used
// by loadCachedDetailsFast — the latter is frequently empty even after a
// refresh, so it is unusable as an existence signal.
//
// Keyed by cli.ID so serverOverride routing (e.g. contact → hrmregister)
// resolves against the target server's tool set. A server with no cached tools
// is simply absent from the map; the build guard treats "absent / empty" as
// "unknown" and keeps the command, so a cold cache never blanks the tree.
func loadCachedToolNames(store *cache.Store, servers []market.ServerDescriptor) map[string]map[string]struct{} {
result := make(map[string]map[string]struct{})
if store == nil {
return result
}
partition := editionPartition()
for _, server := range servers {
slug := strings.TrimSpace(server.CLI.ID)
if slug == "" || strings.TrimSpace(server.Key) == "" {
continue
}
snap, _, err := store.LoadTools(partition, server.Key)
if err != nil || len(snap.Tools) == 0 {
continue
}
names := make(map[string]struct{}, len(snap.Tools))
for _, t := range snap.Tools {
if n := strings.TrimSpace(t.Name); n != "" {
names[n] = struct{}{}
}
}
if len(names) > 0 {
result[slug] = names
}
}
return result
}
// loadCachedDetailsFast reads Detail API tool metadata from disk cache only —
// no network calls. Returns whatever is available (fresh or stale).
func loadCachedDetailsFast(store *cache.Store, servers []market.ServerDescriptor) map[string][]market.DetailTool {
@@ -172,7 +411,7 @@ func loadCachedDetailsFast(store *cache.Store, servers []market.ServerDescriptor
if store == nil {
return result
}
partition := config.DefaultPartition
partition := editionPartition()
for _, server := range servers {
if server.DetailLocator.MCPID <= 0 {
continue
@@ -203,7 +442,7 @@ func fetchDetailsByServerID(ctx context.Context, client *market.Client, servers
if ctx == nil {
ctx = context.Background()
}
partition := config.DefaultPartition
partition := editionPartition()
now := time.Now().UTC()
if store != nil && store.Now != nil {
now = store.Now().UTC()
@@ -330,17 +569,19 @@ func DiscoveryBaseURL() string {
if discoveryBaseURLOverride != "" {
return discoveryBaseURLOverride
}
return cli.DefaultMarketBaseURL
return config.GetMCPBaseURL()
}
// ipv4OnlyHTTPClient returns an HTTP client that forces IPv4 connections
// and uses a short timeout suitable for CLI startup network requests.
// This avoids IPv6 DNS/connect timeouts on hosts without IPv6 networking.
func ipv4OnlyHTTPClient() *http.Client {
// ipv4HTTPClient returns an HTTP client that forces IPv4 connections with
// the given total request timeout. This avoids IPv6 DNS/connect timeouts on
// hosts without IPv6 networking.
func ipv4HTTPClient(timeout time.Duration) *http.Client {
dialer := &net.Dialer{Timeout: 3 * time.Second}
return &http.Client{
Timeout: 5 * time.Second,
Timeout: timeout,
Transport: &http.Transport{
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#236).
Proxy: http.ProxyFromEnvironment,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, "tcp4", addr)
},
@@ -348,6 +589,36 @@ func ipv4OnlyHTTPClient() *http.Client {
}
}
// ipv4OnlyHTTPClient returns an IPv4-forcing HTTP client with a short timeout
// suitable for CLI startup network requests.
func ipv4OnlyHTTPClient() *http.Client {
return ipv4HTTPClient(5 * time.Second)
}
// fetchRegistryServers performs the server-list HTTP fetch honoring the
// active edition's DiscoveryURL override. It is the single source of truth
// for all server-list fetches (startup, async revalidation, explicit
// `cache refresh`); keeping the edition-URL branch in one place prevents
// call sites from drifting out of sync.
func fetchRegistryServers(ctx context.Context, httpClient *http.Client) (market.ListResponse, error) {
if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" {
client := market.NewClient("", httpClient)
if fn := edition.Get().DiscoveryHeaders; fn != nil {
client.Headers = fn()
}
return client.FetchServersFromURL(ctx, editionURL)
}
client := market.NewClient(DiscoveryBaseURL(), httpClient)
return client.FetchServers(ctx, config.DefaultFetchServersLimit)
}
func registryDiscoveryBaseURL() string {
if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" {
return editionURL
}
return DiscoveryBaseURL()
}
// asyncRevalidateRegistry refreshes the registry cache in the background.
// Uses a short timeout derived from the parent context and silently ignores
// errors — the next CLI invocation will pick up the refreshed cache or retry.
@@ -355,14 +626,12 @@ func asyncRevalidateRegistry(parent context.Context, store *cache.Store, partiti
ctx, cancel := context.WithTimeout(parent, 30*time.Second)
defer cancel()
baseURL := DiscoveryBaseURL()
client := market.NewClient(baseURL, ipv4OnlyHTTPClient())
resp, err := client.FetchServers(ctx, config.DefaultFetchServersLimit)
resp, err := fetchRegistryServers(ctx, ipv4OnlyHTTPClient())
if err != nil {
slog.Debug("asyncRevalidateRegistry: fetch failed", "error", err)
return
}
servers := market.NormalizeServers(resp, "market")
servers := market.NormalizeServersForBaseURL(resp, "market", registryDiscoveryBaseURL())
if saveErr := store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}); saveErr != nil {
slog.Debug("asyncRevalidateRegistry: save failed", "error", saveErr)
}
@@ -398,3 +667,8 @@ func mergeTopLevelCommands(commands []*cobra.Command) []*cobra.Command {
})
return out
}
// 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).
+2 -2
View File
@@ -359,7 +359,7 @@ func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadata(t *testing.T
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/cli/discovery/apis":
case r.URL.Path == "/cli/discovery/apis/cedar":
payload := map[string]any{
"metadata": map[string]any{"count": 2, "nextCursor": ""},
"servers": []any{
@@ -433,7 +433,7 @@ func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadataWhenRegistryT
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/cli/discovery/apis":
case r.URL.Path == "/cli/discovery/apis/cedar":
_ = json.NewEncoder(w).Encode(map[string]any{
"metadata": map[string]any{"count": 2, "nextCursor": ""},
"servers": []any{
+203
View File
@@ -0,0 +1,203 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
// captureStderr redirects os.Stderr for the duration of fn and returns what
// was written to it.
func captureStderr(t *testing.T, fn func()) string {
t.Helper()
pipeR, pipeW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe() error = %v", err)
}
origStderr := os.Stderr
os.Stderr = pipeW
defer func() { os.Stderr = origStderr }()
fn()
_ = pipeW.Close()
os.Stderr = origStderr
captured, _ := io.ReadAll(pipeR)
return string(captured)
}
// TestNewLegacyPublicCommandsPanicFallsBackToHelpers verifies the escape
// hatch for a poisoned discovery cache: when the dynamic command build
// panics (e.g. duplicate pflag registration, the pre-1.0.32 lock-out
// "flag redefined: params"), newLegacyPublicCommands must NOT propagate
// the panic. With no on-disk cache to quarantine there is nothing to
// self-heal from, so it degrades to the hardcoded helper commands and
// prints a stderr hint pointing at `dws cache refresh`.
func TestNewLegacyPublicCommandsPanicFallsBackToHelpers(t *testing.T) {
t.Setenv(cli.CacheDirEnv, t.TempDir())
calls := 0
orig := loadDynamicCommandsFn
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
calls++
panic("chat_permission_grant flag redefined: params")
}
t.Cleanup(func() { loadDynamicCommandsFn = orig })
var cmds []*cobra.Command
captured := captureStderr(t, func() {
cmds = newLegacyPublicCommands(context.Background(), nil)
})
if len(cmds) == 0 {
t.Fatalf("newLegacyPublicCommands() = 0 commands after build panic, want helper fallback set")
}
if !strings.Contains(captured, "dws cache refresh") {
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
}
if calls != 1 {
t.Errorf("dynamic build attempts = %d, want 1 (no cache on disk, nothing to quarantine and retry)", calls)
}
}
// TestNewLegacyPublicCommandsSelfHealsPoisonedCache verifies the self-heal
// path: when the build panics AND a discovery cache exists on disk, the
// partition is quarantined (moved aside, kept for inspection) and the build
// retried once. The retry succeeding means the user gets the full dynamic
// command tree with zero manual cache surgery.
func TestNewLegacyPublicCommandsSelfHealsPoisonedCache(t *testing.T) {
tmp := t.TempDir()
t.Setenv(cli.CacheDirEnv, tmp)
store := cache.NewStore(tmp)
partition := editionPartition()
if err := store.SaveTools(partition, "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
calls := 0
orig := loadDynamicCommandsFn
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
calls++
if calls == 1 {
panic("chat_permission_grant flag redefined: params")
}
return []*cobra.Command{{Use: "dynamic-probe"}}
}
t.Cleanup(func() { loadDynamicCommandsFn = orig })
var cmds []*cobra.Command
captured := captureStderr(t, func() {
cmds = newLegacyPublicCommands(context.Background(), nil)
})
if calls != 2 {
t.Fatalf("dynamic build attempts = %d, want 2 (initial + retry after quarantine)", calls)
}
found := false
for _, c := range cmds {
if c.Name() == "dynamic-probe" {
found = true
break
}
}
if !found {
t.Errorf("newLegacyPublicCommands() did not return the rebuilt dynamic command tree; got %d commands without 'dynamic-probe'", len(cmds))
}
quarantines, _ := filepath.Glob(filepath.Join(tmp, "*.quarantined"))
if len(quarantines) != 1 {
t.Fatalf("quarantine dirs = %v, want exactly 1", quarantines)
}
if _, err := os.Stat(filepath.Join(quarantines[0], "tools", "poisoned-server.json")); err != nil {
t.Errorf("poisoned snapshot not preserved in quarantine: %v", err)
}
if !strings.Contains(captured, "rebuilding from a fresh fetch") {
t.Errorf("stderr = %q, want a note about rebuilding from a fresh fetch", captured)
}
if strings.Contains(captured, "dws cache refresh") {
t.Errorf("stderr = %q, must not tell the user to run 'dws cache refresh' when the rebuild succeeded", captured)
}
}
// TestNewLegacyPublicCommandsSecondPanicDegradesToHelpers verifies the final
// safety net: if the rebuild after quarantine panics again (remote envelope
// still poisoned, or offline), the CLI degrades to helper commands and keeps
// the `dws cache refresh` hint.
func TestNewLegacyPublicCommandsSecondPanicDegradesToHelpers(t *testing.T) {
tmp := t.TempDir()
t.Setenv(cli.CacheDirEnv, tmp)
store := cache.NewStore(tmp)
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
calls := 0
orig := loadDynamicCommandsFn
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
calls++
panic("chat_permission_grant flag redefined: params")
}
t.Cleanup(func() { loadDynamicCommandsFn = orig })
var cmds []*cobra.Command
captured := captureStderr(t, func() {
cmds = newLegacyPublicCommands(context.Background(), nil)
})
if calls != 2 {
t.Fatalf("dynamic build attempts = %d, want 2 (initial + retry after quarantine)", calls)
}
if len(cmds) == 0 {
t.Fatalf("newLegacyPublicCommands() = 0 commands after repeated build panics, want helper fallback set")
}
if !strings.Contains(captured, "dws cache refresh") {
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
}
}
// TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath ensures the guard is
// transparent on the happy path: commands returned by the dynamic build
// still reach the caller unchanged.
func TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath(t *testing.T) {
orig := loadDynamicCommandsFn
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
return []*cobra.Command{{Use: "dynamic-probe"}}
}
t.Cleanup(func() { loadDynamicCommandsFn = orig })
cmds := newLegacyPublicCommands(context.Background(), nil)
found := false
for _, c := range cmds {
if c.Name() == "dynamic-probe" {
found = true
break
}
}
if !found {
t.Errorf("newLegacyPublicCommands() lost the dynamic command; got %d commands without 'dynamic-probe'", len(cmds))
}
}
+219
View File
@@ -0,0 +1,219 @@
// 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/cobracmd"
"github.com/spf13/cobra"
)
// TestPickCommands_DynamicWinsLeafConflicts verifies that when the discovery
// envelope produces a dynamic leaf and a helper registers the same-named leaf,
// the dynamic one wins — envelopes remain the runtime authority for behaviour
// they declare. The helper subtree must not slip in via
// mergeTopLevelCommands's LocalFlagCount-based arbitration.
func TestPickCommands_DynamicWinsLeafConflicts(t *testing.T) {
dynTask := &cobra.Command{Use: "task", Short: "dynamic-task", Run: func(*cobra.Command, []string) {}}
dyn := &cobra.Command{Use: "todo", Short: "dynamic"}
dyn.AddCommand(dynTask)
dynamic := []*cobra.Command{dyn}
hlpTask := &cobra.Command{Use: "task", Short: "helper-task", Run: func(*cobra.Command, []string) {}}
hlp := &cobra.Command{Use: "todo", Short: "helper"}
hlp.AddCommand(hlpTask)
helpers := []*cobra.Command{hlp}
got := pickCommands(dynamic, helpers)
if len(got) != 1 || got[0] != dyn {
t.Fatalf("pickCommands returned %v, want [dyn]", got)
}
// The dynamic leaf must still be the one we find under the top-level name.
var found *cobra.Command
for _, c := range got[0].Commands() {
if c.Name() == "task" {
found = c
}
}
if found != dynTask {
t.Fatalf("leaf conflict resolved to helper; want dynamic to win")
}
}
// TestPickCommands_HelperOnlyLeavesAreGrafted verifies that when a helper
// registers siblings the discovery envelope did NOT declare (e.g.
// `chat message send-by-bot`, `chat message recall-by-bot` next to the
// envelope's `chat message send`), those helper-only leaves are grafted into
// the dynamic subtree instead of being dropped. This is a regression guard:
// prior to this fix, pickCommands silently dropped the entire helper subtree
// whenever the top-level product name collided, which disappeared every
// helper-only leaf the envelope didn't cover.
func TestPickCommands_HelperOnlyLeavesAreGrafted(t *testing.T) {
dynMessage := &cobra.Command{Use: "message"}
dynMessage.AddCommand(&cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}})
dyn := &cobra.Command{Use: "chat"}
dyn.AddCommand(dynMessage)
dynamic := []*cobra.Command{dyn}
helperOnlyLeaf := &cobra.Command{Use: "send-by-bot", Run: func(*cobra.Command, []string) {}}
hlpMessage := &cobra.Command{Use: "message"}
hlpMessage.AddCommand(helperOnlyLeaf)
hlp := &cobra.Command{Use: "chat"}
hlp.AddCommand(hlpMessage)
helpers := []*cobra.Command{hlp}
got := pickCommands(dynamic, helpers)
if len(got) != 1 || got[0] != dyn {
t.Fatalf("pickCommands returned %v, want [dyn]", got)
}
var grafted *cobra.Command
for _, child := range dynMessage.Commands() {
if child.Name() == "send-by-bot" {
grafted = child
}
}
if grafted == nil {
t.Fatalf("helper-only leaf send-by-bot was not grafted into dynamic.chat.message")
}
if grafted != helperOnlyLeaf {
t.Fatalf("grafted leaf identity differs from helper-registered leaf")
}
}
// TestPickCommands_HelpersFillUncoveredProducts verifies that helpers whose
// names are NOT in the dynamic set are preserved — the dynamic overlay only
// shadows products it actually covers.
func TestPickCommands_HelpersFillUncoveredProducts(t *testing.T) {
dyn := &cobra.Command{Use: "todo"}
dynamic := []*cobra.Command{dyn}
todoHelper := &cobra.Command{Use: "todo"}
attendanceHelper := &cobra.Command{Use: "attendance"}
chatHelper := &cobra.Command{Use: "chat"}
helpers := []*cobra.Command{todoHelper, attendanceHelper, chatHelper}
got := pickCommands(dynamic, helpers)
names := make(map[string]*cobra.Command, len(got))
for _, c := range got {
names[c.Name()] = c
}
if names["todo"] != dyn {
t.Fatalf("todo = %v, want dynamic", names["todo"])
}
if names["attendance"] != attendanceHelper {
t.Fatalf("attendance not preserved from helpers")
}
if names["chat"] != chatHelper {
t.Fatalf("chat not preserved from helpers")
}
if len(got) != 3 {
t.Fatalf("got %d commands, want 3 (todo+attendance+chat)", len(got))
}
}
// TestPickCommands_EmptyDynamicPreservesHelpers verifies the degenerate case:
// when discovery returns nothing, helpers are the sole source of truth — the
// behaviour must be identical to the pre-refactor append-all code path.
func TestPickCommands_EmptyDynamicPreservesHelpers(t *testing.T) {
todoHelper := &cobra.Command{Use: "todo"}
chatHelper := &cobra.Command{Use: "chat"}
helpers := []*cobra.Command{todoHelper, chatHelper}
got := pickCommands(nil, helpers)
if len(got) != 2 {
t.Fatalf("got %d commands, want 2", len(got))
}
if got[0] != todoHelper || got[1] != chatHelper {
t.Fatalf("pickCommands changed helpers order or identity")
}
}
// 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) {
dyn := &cobra.Command{Use: "todo"}
hlp := &cobra.Command{Use: "chat"}
got := pickCommands([]*cobra.Command{nil, dyn}, []*cobra.Command{nil, hlp})
if len(got) != 2 {
t.Fatalf("got %d commands, want 2 (nils filtered)", len(got))
}
if got[0] != dyn || got[1] != hlp {
t.Fatalf("unexpected ordering or identity after nil filter")
}
}
@@ -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)
}
})
}
}
+148
View File
@@ -0,0 +1,148 @@
package app
import (
"context"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
)
func TestRuntimeRunnerAggregatesCommaSeparatedProfiles(t *testing.T) {
setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_a"),
authLogoutTestToken("corp_b"),
)
authpkg.SetRuntimeProfile("corp_a, corp_b")
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
result, err := runner.Run(context.Background(), executor.Invocation{
Kind: "helper_invocation",
CanonicalProduct: "contact",
Tool: "get_current_user_profile",
Params: map[string]any{"limit": 10},
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if got := authpkg.RuntimeProfile(); got != "corp_a, corp_b" {
t.Fatalf("runtime profile after Run = %q, want restored raw selector", got)
}
content := result.Response["content"].(map[string]any)
if content["multiProfile"] != true {
t.Fatalf("multiProfile = %#v, want true", content["multiProfile"])
}
if content["success"] != true {
t.Fatalf("success = %#v, want true", content["success"])
}
profiles := content["profiles"].([]any)
if len(profiles) != 2 {
t.Fatalf("profiles len = %d, want 2", len(profiles))
}
for i, wantCorpID := range []string{"corp_a", "corp_b"} {
entry := profiles[i].(map[string]any)
if entry["corpId"] != wantCorpID {
t.Fatalf("profiles[%d].corpId = %#v, want %q", i, entry["corpId"], wantCorpID)
}
if entry["ok"] != true {
t.Fatalf("profiles[%d].ok = %#v, want true", i, entry["ok"])
}
resultPayload := entry["result"].(map[string]any)
if resultPayload["runtimeProfile"] != wantCorpID {
t.Fatalf("profiles[%d].result.runtimeProfile = %#v, want %q", i, resultPayload["runtimeProfile"], wantCorpID)
}
}
}
func TestRuntimeRunnerDeduplicatesCommaSeparatedProfilesByCorpID(t *testing.T) {
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
authpkg.SetRuntimeProfile("corp_a, corp_a org,corp_b")
selections, multi, err := resolveMultiProfileSelections(configDir, authpkg.RuntimeProfile())
if err != nil {
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
}
if !multi {
t.Fatal("multi = false, want true")
}
if len(selections) != 2 {
t.Fatalf("selections len = %d, want 2", len(selections))
}
if selections[0].Profile.CorpID != "corp_a" || selections[1].Profile.CorpID != "corp_b" {
t.Fatalf("resolved corp IDs = %q, %q; want corp_a, corp_b", selections[0].Profile.CorpID, selections[1].Profile.CorpID)
}
}
func TestRuntimeRunnerKeepsSingleProfileBehavior(t *testing.T) {
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
authpkg.SetRuntimeProfile("corp_a")
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
result, err := runner.Run(context.Background(), executor.Invocation{
Kind: "helper_invocation",
CanonicalProduct: "contact",
Tool: "get_current_user_profile",
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if _, ok := result.Response["content"].(map[string]any)["multiProfile"]; ok {
t.Fatalf("single profile unexpectedly returned aggregate content: %#v", result.Response)
}
if got := authpkg.RuntimeProfile(); got != "corp_a" {
t.Fatalf("runtime profile after Run = %q, want corp_a", got)
}
}
func TestCommaNamedProfileStillResolvesAsSingleProfile(t *testing.T) {
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_comma"), authLogoutTestToken("corp_other"))
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
for i := range cfg.Profiles {
if cfg.Profiles[i].CorpID == "corp_comma" {
cfg.Profiles[i].Name = "alpha,beta"
}
}
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
selections, multi, err := resolveMultiProfileSelections(configDir, "alpha,beta")
if err != nil {
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
}
if multi {
t.Fatalf("multi = true, want false; selections=%#v", selections)
}
}
func TestCommaSeparatedProfileRejectsEmptySelector(t *testing.T) {
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
_, _, err := resolveMultiProfileSelections(configDir, "corp_a,,corp_b")
if err == nil {
t.Fatal("resolveMultiProfileSelections() error = nil, want validation error")
}
if !strings.Contains(err.Error(), "empty profile selector") {
t.Fatalf("error = %q, want empty profile selector", err.Error())
}
}
type multiProfileFallbackRunner struct{}
func (multiProfileFallbackRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
invocation.Implemented = true
return executor.Result{
Invocation: invocation,
Response: map[string]any{
"content": map[string]any{
"runtimeProfile": authpkg.RuntimeProfile(),
"tool": invocation.Tool,
},
},
}, nil
}
+83
View File
@@ -0,0 +1,83 @@
package app
import (
"bytes"
"os"
"path/filepath"
"testing"
)
// writeMultiSkillSrc creates a fake multi skill source tree with the given
// subdir names, each containing a minimal SKILL.md.
func writeMultiSkillSrc(t *testing.T, names ...string) string {
t.Helper()
src := t.TempDir()
for _, n := range names {
dir := filepath.Join(src, n)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+n+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
return src
}
func contains(ss []string, want string) bool {
for _, s := range ss {
if s == want {
return true
}
}
return false
}
// dws-shared must ship even when --skill narrows the set to a single product.
func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
src := writeMultiSkillSrc(t, "dws-shared", "dingtalk-aitable", "dingtalk-calendar")
all, err := listMultiSkillNames(src)
if err != nil {
t.Fatal(err)
}
if !contains(all, "dws-shared") {
t.Fatalf("listMultiSkillNames did not enumerate dws-shared: %v", all)
}
filtered, err := filterMultiSkillNames(all, []string{"aitable"}, nil)
if err != nil {
t.Fatal(err)
}
if contains(filtered, "dws-shared") {
t.Fatalf("precondition: filter should drop dws-shared for -s aitable: %v", filtered)
}
final := ensureMandatorySharedSkill(filtered, all)
if !contains(final, "dws-shared") {
t.Fatalf("ensureMandatorySharedSkill must re-add dws-shared: %v", final)
}
// Actually install with the filtered+mandatory set and assert dws-shared landed.
dest := t.TempDir()
var out, errOut bytes.Buffer
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
t.Fatalf("install: %v (%s)", err, errOut.String())
}
if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil {
t.Fatalf("dws-shared not installed with -s aitable: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "dingtalk-aitable", "SKILL.md")); err != nil {
t.Fatalf("dingtalk-aitable not installed: %v", err)
}
}
// When the source has no dws-shared (older layout), nothing is forced.
func TestP1SharedNoopWhenAbsent(t *testing.T) {
src := writeMultiSkillSrc(t, "dingtalk-aitable")
all, err := listMultiSkillNames(src)
if err != nil {
t.Fatal(err)
}
final := ensureMandatorySharedSkill([]string{"dingtalk-aitable"}, all)
if contains(final, "dws-shared") {
t.Fatalf("must not invent dws-shared when source lacks it: %v", final)
}
}
+421 -108
View File
@@ -14,6 +14,7 @@
package app
import (
"bytes"
"context"
"encoding/json"
stderrors "errors"
@@ -32,7 +33,9 @@ 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/fatih/color"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
)
const (
@@ -43,8 +46,16 @@ 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
type patSuppressBrowserOpenKeyType struct{}
var patSuppressBrowserOpenKey = patSuppressBrowserOpenKeyType{}
// PatScopeError holds information about a missing PAT scope.
type PatScopeError struct {
OriginalError string
@@ -147,67 +158,104 @@ func extractPatScopeError(err error) *PatScopeError {
// PrintPatAuthError prints a human-readable PAT authorization error.
func PrintPatAuthError(w io.Writer, scopeErr *PatScopeError) {
bold := color.New(color.Bold).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc()
dim := color.New(color.Faint).SprintFunc()
green := color.New(color.FgGreen).SprintFunc()
fmt.Fprintln(w)
fmt.Fprintf(w, "{\n")
fmt.Fprintf(w, " %s: %s,\n", bold("\"ok\""), "false")
fmt.Fprintf(w, " %s: %q,\n", bold("\"identity\""), scopeErr.Identity)
fmt.Fprintf(w, " %s: {\n", bold("\"error\""))
fmt.Fprintf(w, " %s: %q,\n", bold("\"type\""), scopeErr.ErrorType)
fmt.Fprintf(w, " %s: %q,\n", bold("\"message\""), scopeErr.Message)
fmt.Fprintf(w, " %s: %q\n", bold("\"hint\""), scopeErr.Hint)
fmt.Fprintf(w, " %s: %s,\n", tui.Bold("\"ok\""), "false")
fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"identity\""), scopeErr.Identity)
fmt.Fprintf(w, " %s: {\n", tui.Bold("\"error\""))
fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"type\""), scopeErr.ErrorType)
fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"message\""), scopeErr.Message)
fmt.Fprintf(w, " %s: %q\n", tui.Bold("\"hint\""), scopeErr.Hint)
fmt.Fprintf(w, " }\n")
fmt.Fprintf(w, "}\n")
fmt.Fprintln(w)
// Print authorization instructions
fmt.Fprintf(w, "%s %s\n", green("▶"), bold("需要额外授权"))
fmt.Fprintf(w, "%s %s\n", tui.StateMark("warning"), tui.Bold("需要额外授权"))
fmt.Fprintln(w)
fmt.Fprintf(w, " %s %s\n", dim("#"), dim("运行以下命令完成授权"))
fmt.Fprintf(w, " %s %s\n", tui.Dim("#"), tui.Dim("运行以下命令完成授权"))
if scopeErr.MissingScope != "" {
fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan(fmt.Sprintf("dws auth login --scope %q", scopeErr.MissingScope)))
fmt.Fprintf(w, " %s %s\n", tui.Cyan("$"), tui.Cyan(fmt.Sprintf("dws auth login --scope %q", scopeErr.MissingScope)))
} else {
fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan("dws auth login"))
fmt.Fprintf(w, " %s %s\n", tui.Cyan("$"), tui.Cyan("dws auth login"))
}
fmt.Fprintln(w)
fmt.Fprintf(w, " %s 在浏览器中打开授权链接,完成授权后重新执行命令\n", dim("ℹ"))
fmt.Fprintf(w, " %s 在浏览器中打开授权链接,完成授权后重新执行命令\n", tui.Dim("ℹ"))
fmt.Fprintln(w)
}
// PrintPatAuthJSON prints a machine-readable PAT authorization error.
func PrintPatAuthJSON(w io.Writer, scopeErr *PatScopeError) {
payload := map[string]any{
"ok": false,
"identity": scopeErr.Identity,
"error": map[string]any{
"type": scopeErr.ErrorType,
"message": scopeErr.Message,
"hint": scopeErr.Hint,
},
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(ctx context.Context, configDir string) bool {
if suppressed, _ := ctx.Value(patSuppressBrowserOpenKey).(bool); suppressed {
return false
}
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
}
if rawURI := patAuthorizationURIFromData(data); rawURI != "" {
authURL := apperrors.PATAuthorizationURL(rawURI)
data["uri"] = authURL
delete(data, "authUrl")
delete(data, "authorizationUrl")
}
data["openBrowser"] = openBrowser
encoded, err := marshalSingleLineJSONNoHTMLEscape(payload)
if err != nil {
return raw
}
return string(encoded)
}
func patAuthorizationURIFromData(data map[string]any) string {
for _, key := range []string{"uri", "authUrl", "authorizationUrl"} {
value, _ := data[key].(string)
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
// WaitForPatAuthorization polls until the user completes authorization or timeout.
// It returns true if authorization was completed, false if timed out or cancelled.
func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool {
bold := color.New(color.Bold).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc()
green := color.New(color.FgGreen).SprintFunc()
red := color.New(color.FgRed).SprintFunc()
dim := color.New(color.Faint).SprintFunc()
timeout := PatAuthRetryTimeout
deadline := time.Now().Add(timeout)
pollTicker := time.NewTicker(PatAuthPollInterval)
@@ -215,21 +263,21 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
start := time.Now()
fmt.Fprintln(output)
fmt.Fprintf(output, "%s %s\n", yellow("⏳"), bold("等待用户授权..."))
fmt.Fprintf(output, " %s 请在另一个终端完成 dws auth login 授权\n", dim("ℹ"))
fmt.Fprintf(output, " %s 超时时间: %s\n", dim("⏱"), timeout)
fmt.Fprintf(output, "%s %s\n", tui.StateMark("pending"), tui.Bold("等待用户授权..."))
fmt.Fprintf(output, " %s 请在另一个终端完成 dws auth login 授权\n", tui.Dim("ℹ"))
fmt.Fprintf(output, " %s 超时时间: %s\n", tui.Dim("⏱"), timeout)
fmt.Fprintln(output)
pollCount := 0
for {
select {
case <-ctx.Done():
fmt.Fprintf(output, "%s 操作已取消\n", red("✗"))
fmt.Fprintf(output, "%s 操作已取消\n", tui.StateMark("error"))
return false
case <-time.After(time.Until(deadline)):
fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", red("✗"), timeout)
fmt.Fprintf(output, " %s 请重新执行命令\n", dim("ℹ"))
fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", tui.StateMark("error"), timeout)
fmt.Fprintf(output, " %s 请重新执行命令\n", tui.Dim("ℹ"))
return false
case <-pollTicker.C:
@@ -242,7 +290,7 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
if err == nil && tokenData != nil {
if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() {
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
green("✓"), bold("授权成功!"), elapsed, remaining)
tui.StateMark("ok"), tui.Bold("授权成功!"), elapsed, remaining)
fmt.Fprintln(output)
return true
}
@@ -250,7 +298,7 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
// Show polling status
fmt.Fprintf(output, "\r%s [%d] 等待授权中... (%s 已用, %s 剩余) ",
dim("⟳"), pollCount, elapsed, remaining)
tui.Dim("⟳"), pollCount, elapsed, remaining)
}
}
}
@@ -258,6 +306,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)
@@ -277,8 +338,7 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati
// Retry the invocation
fmt.Fprintln(output)
fmt.Fprintf(output, "%s %s\n", color.New(color.FgGreen).SprintFunc()("▶"),
color.New(color.Bold).SprintFunc()("授权完成,正在重试..."))
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权完成,正在重试..."))
fmt.Fprintln(output)
return runner.Run(ctx, invocation)
@@ -289,6 +349,9 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati
const (
// patPollInterval is how often we poll the device flow status endpoint.
patPollInterval = 2 * time.Second
// patMaxPollInterval caps a server-provided poll interval so a malformed
// response cannot make the CLI look permanently stuck.
patMaxPollInterval = 30 * time.Second
// patPollTimeout is the maximum time to wait for user authorization via device flow.
patPollTimeout = 10 * time.Minute
)
@@ -299,12 +362,108 @@ type patRetryingKeyType struct{}
var patRetryingKey = patRetryingKeyType{}
type patRetryRunnerFunc func(context.Context, executor.Invocation) (executor.Result, error)
func (f patRetryRunnerFunc) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
return f(ctx, invocation)
}
// IsPatRetrying returns true if the current context is already in a PAT retry.
func IsPatRetrying(ctx context.Context) bool {
v, _ := ctx.Value(patRetryingKey).(bool)
return v
}
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 unless it is the known legacy DingTalk hash-route variant.
// That variant is normalized by the PAT error contract helper before being
// printed, opened, or returned in structured output.
return openBrowserFunc(apperrors.PATAuthorizationURL(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)
}
func runDirectPATAuthCheck(
ctx context.Context,
globalFlags *GlobalFlags,
patErr *apperrors.PATError,
retry func(context.Context) error,
output io.Writer,
) error {
if retry == nil {
return patErr
}
return runDirectPATAuthCheckWithMode(ctx, globalFlags, patErr, retry, output, true)
}
func runDirectPATAuthCheckWaitOnly(
ctx context.Context,
globalFlags *GlobalFlags,
patErr *apperrors.PATError,
output io.Writer,
) error {
ctx = context.WithValue(ctx, patSuppressBrowserOpenKey, true)
return runDirectPATAuthCheckWithMode(ctx, globalFlags, patErr, nil, output, false)
}
func runDirectPATAuthCheckWithMode(
ctx context.Context,
globalFlags *GlobalFlags,
patErr *apperrors.PATError,
retry func(context.Context) error,
output io.Writer,
retryAfterApproval bool,
) error {
if retryAfterApproval && retry == nil {
return patErr
}
runner := &runtimeRunner{
globalFlags: globalFlags,
fallback: patRetryRunnerFunc(func(retryCtx context.Context, invocation executor.Invocation) (executor.Result, error) {
if retry != nil {
if err := retry(retryCtx); err != nil {
return executor.Result{}, err
}
}
invocation.Implemented = true
return executor.Result{
Invocation: invocation,
Response: map[string]any{
"ok": true,
},
}, nil
}),
}
_, err := handlePatAuthCheck(ctx, runner, executor.Invocation{
Kind: "direct_pat_authorization",
Stage: "auth_login_recommend",
CanonicalProduct: defaultPATProductID,
Tool: "pat.batch_grant",
CanonicalPath: "pat.batch_grant",
Params: map[string]any{
"retryAfterApproval": retryAfterApproval,
},
}, patErr, defaultConfigDir(), output)
return err
}
// 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,
@@ -322,25 +481,42 @@ func handlePatAuthCheck(
var patData struct {
Code string `json:"code"`
Data struct {
Desc string `json:"desc"`
FlowID string `json:"flowId"`
URI string `json:"uri"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
Desc string `json:"desc"`
FlowID string `json:"flowId"`
URI string `json:"uri"`
AuthURL string `json:"authUrl"`
AuthorizationURL string `json:"authorizationUrl"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
PollIntervalSecs int `json:"pollIntervalSeconds"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(patErr.RawJSON), &patData); err != nil {
return executor.Result{}, patErr
}
if patData.Data.URI == "" {
patData.Data.URI = patData.Data.AuthURL
}
if patData.Data.URI == "" {
patData.Data.URI = patData.Data.AuthorizationURL
}
slog.Debug("PAT auth check",
"clientId", patData.Data.ClientID,
"flowId", patData.Data.FlowID,
"hasSecret", patData.Data.ClientSecret != "",
)
hostOwnedPAT := authpkg.HostOwnsPATFlow()
openBrowser := currentPATOpenBrowser(ctx, 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,75 +529,88 @@ 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 != "" {
_ = openPATAuthorizationURI(patData.Data.URI)
}
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, openBrowser)}
}
fmt.Fprintln(output)
fmt.Fprintf(output, "%s %s\n", tui.StateMark("warning"), tui.Bold("需要 PAT 授权"))
if patData.Data.Desc != "" {
fmt.Fprintf(output, " %s %s\n", tui.Dim("ℹ"), patData.Data.Desc)
}
if patData.Data.URI != "" {
authURL := apperrors.PATAuthorizationURL(patData.Data.URI)
fmt.Fprintf(output, " %s 授权链接: %s\n", tui.Dim("🔗"), authURL)
fmt.Fprintln(output)
if openBrowser {
_ = openPATAuthorizationURI(authURL)
}
}
bold := color.New(color.Bold).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc()
greenFn := color.New(color.FgGreen).SprintFunc()
yellowFn := color.New(color.FgYellow).SprintFunc()
redFn := color.New(color.FgRed).SprintFunc()
dim := color.New(color.Faint).SprintFunc()
fmt.Fprintln(output)
fmt.Fprintf(output, "%s %s\n", greenFn("▶"), bold("需要 PAT 授权"))
if patData.Data.Desc != "" {
fmt.Fprintf(output, " %s %s\n", dim("ℹ"), patData.Data.Desc)
}
if patData.Data.URI != "" {
fmt.Fprintf(output, " %s %s\n\n", dim("🔗"), cyan(patData.Data.URI))
// Best-effort browser open.
_ = tryOpenBrowser(patData.Data.URI)
}
// If no flowId, we can't poll — fall back to returning PATError for host-app.
if patData.Data.FlowID == "" {
fmt.Fprintln(output)
return executor.Result{}, patErr
}
// Poll the device flow status until user authorizes, rejects, or timeout.
fmt.Fprintf(output, "%s %s\n", yellowFn("⏳"), bold("等待用户授权..."))
fmt.Fprintf(output, " %s 请在浏览器中完成授权,超时时间: %s\n", dim("ℹ"), patPollTimeout)
fmt.Fprintf(output, "%s %s\n", tui.StateMark("pending"), tui.Bold("等待用户授权..."))
fmt.Fprintf(output, " %s 请在浏览器中完成授权,超时时间: %s\n", tui.Dim("ℹ"), patPollTimeout)
fmt.Fprintln(output)
pollCtx, cancel := context.WithTimeout(ctx, patPollTimeout)
defer cancel()
status, authCode, err := pollPatDeviceFlow(pollCtx, patData.Data.FlowID, configDir, output)
status, authCode, err := pollPatDeviceFlowWithInterval(
pollCtx, patData.Data.FlowID, configDir, output,
resolvePATPollInterval(patData.Data.PollIntervalSecs),
)
if err != nil {
fmt.Fprintf(output, "%s 轮询授权状态失败: %v\n", redFn("✗"), err)
fmt.Fprintf(output, "%s 轮询授权状态失败: %v\n", tui.StateMark("error"), err)
return executor.Result{}, patErr
}
switch status {
case authpkg.StatusApproved:
fmt.Fprintf(output, "%s %s\n", greenFn("✓"), bold("授权成功!"))
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.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)
tokenData, exchErr := authpkg.ExchangeCodeForToken(ctx, configDir, authCode)
if exchErr != nil {
slog.Warn("PAT retry: exchangeCode failed, retrying with existing token", "error", exchErr)
fmt.Fprintf(output, " %s 换取新 token 失败: %v (将使用现有凭证重试)\n", yellowFn("⚠"), exchErr)
fmt.Fprintf(output, " %s 换取新 token 失败: %v (将使用现有凭证重试)\n", tui.StateMark("warning"), exchErr)
} else {
if err := authpkg.SaveTokenData(configDir, tokenData); err != nil {
slog.Warn("PAT retry: failed to save new token", "error", err)
fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", yellowFn("⚠"), err)
fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", tui.StateMark("warning"), err)
} else {
slog.Debug("PAT retry: token refreshed and saved")
}
@@ -431,13 +620,23 @@ func handlePatAuthCheck(
// Clear token cache so the new credentials take effect.
ResetRuntimeTokenCache()
if shouldSkipPATRetryAfterApproval(invocation) {
invocation.Implemented = true
return executor.Result{
Invocation: invocation,
Response: map[string]any{
"ok": true,
},
}, nil
}
// Workaround: brief delay to let server-side authorization state propagate
// before retrying. Without this the retry may use stale credentials.
slog.Debug("PAT retry: waiting for server-side state propagation", "delay", "1s")
time.Sleep(1 * time.Second)
// Retry the original invocation with pat-retrying flag to prevent recursion.
fmt.Fprintf(output, "%s %s\n", greenFn("▶"), bold("授权完成,正在重试..."))
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权完成,正在重试..."))
fmt.Fprintln(output)
slog.Debug("PAT retry: identity env check",
"DWS_CLIENT_ID", os.Getenv("DWS_CLIENT_ID"),
@@ -446,7 +645,7 @@ func handlePatAuthCheck(
return r.Run(retryCtx, invocation)
case authpkg.StatusRejected:
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("用户已拒绝授权"))
fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("用户已拒绝授权"))
return executor.Result{}, apperrors.NewAuth(
"用户已拒绝授权",
apperrors.WithReason("pat_auth_rejected"),
@@ -454,7 +653,7 @@ func handlePatAuthCheck(
)
case authpkg.StatusExpired:
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("授权超时"))
fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("授权超时"))
return executor.Result{}, apperrors.NewAuth(
"授权超时",
apperrors.WithReason("pat_auth_expired"),
@@ -462,7 +661,7 @@ func handlePatAuthCheck(
)
case authpkg.StatusCancelled:
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("操作已取消"))
fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("操作已取消"))
return executor.Result{}, apperrors.NewAuth(
"操作已取消",
apperrors.WithReason("pat_auth_cancelled"),
@@ -470,15 +669,108 @@ func handlePatAuthCheck(
)
default:
fmt.Fprintf(output, "%s 未知授权状态: %s\n", redFn("✗"), status)
fmt.Fprintf(output, "%s 未知授权状态: %s\n", tui.StateMark("error"), status)
return executor.Result{}, patErr
}
}
func shouldSkipPATRetryAfterApproval(invocation executor.Invocation) bool {
if invocation.Params == nil {
return false
}
value, ok := invocation.Params["retryAfterApproval"]
if !ok {
return false
}
retry, ok := value.(bool)
return ok && !retry
}
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 := marshalSingleLineJSONNoHTMLEscape(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 := jsonutil.Marshal(payload)
if err != nil {
return `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED"}`
}
return string(b)
}
func marshalSingleLineJSONNoHTMLEscape(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, err
}
out := buf.Bytes()
if len(out) > 0 && out[len(out)-1] == '\n' {
out = out[:len(out)-1]
}
return out, nil
}
// pollPatDeviceFlow polls the PAT device flow status endpoint until a terminal
// state (APPROVED/REJECTED/EXPIRED) is reached or the context is cancelled.
// Returns the final status string and the authCode (non-empty only on APPROVED).
func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, output io.Writer) (string, string, error) {
return pollPatDeviceFlowWithInterval(ctx, flowID, configDir, output, patPollInterval)
}
func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir string, output io.Writer, interval time.Duration) (string, string, error) {
if interval <= 0 {
interval = patPollInterval
}
pollURL := fmt.Sprintf("%s%s?flowId=%s",
authpkg.GetMCPBaseURL(), authpkg.DevicePollPath, url.QueryEscape(flowID))
@@ -495,10 +787,9 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out
},
}
ticker := time.NewTicker(patPollInterval)
ticker := time.NewTicker(interval)
defer ticker.Stop()
dim := color.New(color.Faint).SprintFunc()
pollCount := 0
for {
@@ -510,7 +801,7 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out
return authpkg.StatusExpired, "", nil
case <-ticker.C:
pollCount++
fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", dim("⟳"), pollCount)
fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", tui.Dim("⟳"), pollCount)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil)
if err != nil {
@@ -537,41 +828,63 @@ 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
}
}
}
}
// tryOpenBrowser opens url in the default browser; errors are silently ignored.
func tryOpenBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
func resolvePATPollInterval(seconds int) time.Duration {
if seconds <= 0 {
return patPollInterval
}
interval := time.Duration(seconds) * time.Second
if interval < time.Second {
return time.Second
}
if interval > patMaxPollInterval {
return patMaxPollInterval
}
return interval
}
func browserOpenCommand(goos, rawURL string) *exec.Cmd {
switch goos {
case "darwin":
cmd = exec.Command("open", url)
return exec.Command("open", rawURL)
case "linux":
cmd = exec.Command("xdg-open", url)
return exec.Command("xdg-open", rawURL)
case "windows":
cmd = exec.Command("cmd", "/c", "start", url)
return exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
default:
return nil
}
}
// tryOpenBrowser opens rawURL in the default browser; errors are silently ignored.
func tryOpenBrowser(rawURL string) error {
cmd := browserOpenCommand(runtime.GOOS, rawURL)
if cmd == nil {
return nil
}
return cmd.Start()
}
File diff suppressed because it is too large Load Diff
+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
}
+209
View File
@@ -0,0 +1,209 @@
// 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)
}
}
// nil existingTools: this overlay is built from the plugin's own live tool
// list (detailsByID is derived from it), so there are no phantom leaves to
// guard against here.
cmds := compat.BuildDynamicCommands(
[]market.ServerDescriptor{descriptor}, runner, detailsByID, nil)
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)
}
})
}
}
+82
View File
@@ -0,0 +1,82 @@
package app
import (
"os"
"reflect"
"testing"
)
func TestNormalizeProfileFlagArgsAcceptsUnquotedCommaContinuation(t *testing.T) {
cases := []struct {
name string
args []string
want []string
}{
{
name: "root profile before command",
args: []string{"--mock", "--profile", "corpA,", "corpB", "contact", "user", "get-self"},
want: []string{"--mock", "--profile", "corpA,corpB", "contact", "user", "get-self"},
},
{
name: "profile after leaf command",
args: []string{"contact", "user", "get-self", "--profile", "corpA,", "corpB", "--format", "json"},
want: []string{"contact", "user", "get-self", "--profile", "corpA,corpB", "--format", "json"},
},
{
name: "equals form",
args: []string{"--profile=corpA,", "corpB", "contact", "user", "get-self"},
want: []string{"--profile=corpA,corpB", "contact", "user", "get-self"},
},
{
name: "three profiles",
args: []string{"--profile", "corpA,", "corpB,", "corpC", "contact", "user", "get-self"},
want: []string{"--profile", "corpA,corpB,corpC", "contact", "user", "get-self"},
},
{
name: "already quoted by shell remains unchanged",
args: []string{"--profile", "corpA, corpB", "contact", "user", "get-self"},
want: []string{"--profile", "corpA, corpB", "contact", "user", "get-self"},
},
{
name: "single profile remains unchanged",
args: []string{"--profile", "corpA", "contact", "user", "get-self"},
want: []string{"--profile", "corpA", "contact", "user", "get-self"},
},
{
name: "trailing comma before next flag remains validation input",
args: []string{"--profile", "corpA,", "--format", "json", "contact", "user", "get-self"},
want: []string{"--profile", "corpA,", "--format", "json", "contact", "user", "get-self"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, _ := normalizeProfileFlagArgs(tc.args)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("normalizeProfileFlagArgs() = %#v, want %#v", got, tc.want)
}
})
}
}
func TestPreparseProfileFlagUsesNormalizedProfileArgs(t *testing.T) {
got := preparseProfileFlag([]string{"--profile", "corpA,", "corpB", "contact", "user", "get-self"})
if got != "corpA,corpB" {
t.Fatalf("preparseProfileFlag() = %q, want corpA,corpB", got)
}
}
func TestNormalizeProcessProfileArgsRestoresOriginalArgv(t *testing.T) {
oldArgs := os.Args
t.Cleanup(func() { os.Args = oldArgs })
os.Args = []string{"dws", "--profile", "corpA,", "corpB", "contact", "user", "get-self"}
restore := normalizeProcessProfileArgs()
if want := []string{"dws", "--profile", "corpA,corpB", "contact", "user", "get-self"}; !reflect.DeepEqual(os.Args, want) {
t.Fatalf("os.Args after normalize = %#v, want %#v", os.Args, want)
}
restore()
if want := []string{"dws", "--profile", "corpA,", "corpB", "contact", "user", "get-self"}; !reflect.DeepEqual(os.Args, want) {
t.Fatalf("os.Args after restore = %#v, want %#v", os.Args, want)
}
}
+747
View File
@@ -0,0 +1,747 @@
// 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"
"errors"
"fmt"
"io"
"sort"
"strings"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
"github.com/spf13/cobra"
)
func newProfileCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "profile",
Short: "组织 profile 管理",
Long: `管理本机已登录的钉钉组织 profile。
每个 profile 对应一个已授权组织。业务命令可通过全局 --profile 临时指定组织,
profile switch/use 才会持久修改默认组织上下文。`,
Example: ` dws profile list
dws profile switch
dws profile switch <corpId>
dws profile switch -
dws --profile <corpId> contact user get-self`,
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
cmd.AddCommand(newProfileListCommand(), newProfileSwitchCommand(), newProfileUseCommand())
return cmd
}
func newProfileListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "列出已登录组织 profile",
Long: "列出本机已登录的所有组织 profile,包含当前组织、主组织、组织名、corpId、状态和用户信息。",
Example: ` dws profile list
dws profile list --format json`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
configDir := defaultConfigDir()
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
}
format, _ := cmd.Root().PersistentFlags().GetString("format")
if strings.EqualFold(strings.TrimSpace(format), "json") {
return writeProfileListJSON(cmd.OutOrStdout(), cfg)
}
writeProfileListTable(cmd.OutOrStdout(), cfg)
return nil
},
}
}
func newProfileUseCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "use [name|corpId|-]",
Short: "切换当前组织 profile(兼容 profile switch)",
Long: "兼容命令,语义等同于 dws profile switch。可用组织名、profile 名、corpId 或 - 切回上一个组织。",
Example: ` dws profile use <corpId>
dws profile use --name "钉钉"
dws profile use -`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runProfileSwitchCommand(cmd, args)
},
}
addProfileSwitchSelectorFlags(cmd)
return cmd
}
func newProfileSwitchCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "switch [name|corpId|-]",
Short: "切换当前组织 profile",
Long: `切换默认组织 profile,并记录 previousProfile 以支持 dws profile switch - 快速切回。
不带参数时,交互终端会展示组织选择器;非交互环境请显式传入组织名、profile 名或 corpId。
需要只影响单次业务命令时,请使用全局 --profile。`,
Example: ` dws profile switch
dws profile switch <corpId>
dws profile switch --corpId <corpId>
dws profile switch --name "钉钉"
dws profile switch -
dws --profile <corpId> contact user get-self`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runProfileSwitchCommand(cmd, args)
},
}
addProfileSwitchSelectorFlags(cmd)
return cmd
}
func addProfileSwitchSelectorFlags(cmd *cobra.Command) {
cmd.Flags().String("corpId", "", "按 corpId 直接切换组织 profile")
cmd.Flags().String("corp-id", "", "按 corpId 直接切换组织 profile")
cmd.Flags().String("corpid", "", "按 corpId 直接切换组织 profile")
cmd.Flags().String("corp", "", "按 corpId 直接切换组织 profile")
cmd.Flags().String("name", "", "按组织名或 profile 名直接切换组织 profile")
_ = cmd.Flags().MarkHidden("corp-id")
_ = cmd.Flags().MarkHidden("corpid")
_ = cmd.Flags().MarkHidden("corp")
}
var (
profileSwitchSelector = selectProfileSwitchProfile
profileSwitchInteractiveTerminal = isInteractiveTerminal
)
const (
profileSwitchVisibleOptions = 5
profileSwitchCellPadding = 1
profileSwitchOrgWidth = 34
profileSwitchStatusWidth = 10
)
var profileSwitchRenderer = newProfileSwitchRenderer()
func newProfileSwitchRenderer() *lipgloss.Renderer {
renderer := lipgloss.NewRenderer(io.Discard)
renderer.SetColorProfile(termenv.TrueColor)
renderer.SetHasDarkBackground(true)
return renderer
}
func runProfileSwitchCommand(cmd *cobra.Command, args []string) error {
configDir := defaultConfigDir()
selector, err := profileSwitchSelectorFromCommand(cmd, args)
if err != nil {
return err
}
usedTUI := false
if selector == "" {
selector, err = profileSwitchSelector(cmd, configDir)
if err != nil {
return err
}
usedTUI = true
}
return switchProfileAndWrite(cmd, configDir, selector, usedTUI)
}
func profileSwitchSelectorFromCommand(cmd *cobra.Command, args []string) (string, error) {
selectors := make([]string, 0, 2)
if len(args) > 0 {
selectors = append(selectors, strings.TrimSpace(args[0]))
}
for _, name := range []string{"corpId", "corp-id", "corpid", "corp", "name"} {
value, changed := changedStringFlag(cmd, name)
if !changed {
continue
}
if value == "" {
return "", apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", name))
}
selectors = append(selectors, value)
}
if len(selectors) == 0 {
return "", nil
}
selector := selectors[0]
for _, candidate := range selectors[1:] {
if candidate != selector {
return "", apperrors.NewValidation("只能指定一个组织选择器,请使用位置参数或 --corpId/--name 其中一种")
}
}
return selector, nil
}
func changedStringFlag(cmd *cobra.Command, name string) (string, bool) {
if cmd == nil || cmd.Flags() == nil {
return "", false
}
flag := cmd.Flags().Lookup(name)
if flag == nil || !flag.Changed {
return "", false
}
return strings.TrimSpace(flag.Value.String()), true
}
func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedTUI bool) error {
var (
profile *authpkg.Profile
err error
)
if strings.TrimSpace(selector) == "-" {
profile, err = authpkg.UsePreviousProfile(configDir)
} else {
profile, err = authpkg.SetCurrentProfile(configDir, selector)
}
if err != nil {
return apperrors.NewValidation(err.Error())
}
ResetRuntimeTokenCache()
clearCompatCache()
format, _ := cmd.Root().PersistentFlags().GetString("format")
if strings.EqualFold(strings.TrimSpace(format), "json") && !(usedTUI && authLoginAllowsInteractiveDefault(cmd, format)) {
cfg, loadErr := authpkg.LoadProfiles(configDir)
if loadErr != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", loadErr))
}
return writeProfileUseJSON(cmd.OutOrStdout(), profile, cfg)
}
fmt.Fprintln(cmd.OutOrStdout(), profileUseMessage(profile))
return nil
}
func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, error) {
if !profileSwitchInteractiveTerminal() {
return "", apperrors.NewValidation("profile selector required in non-interactive mode; use dws profile switch <name|corpId>")
}
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
}
if cfg == nil || len(cfg.Profiles) == 0 {
return "", apperrors.NewValidation("未找到已登录 profile,请先运行 dws auth login")
}
choice := strings.TrimSpace(cfg.CurrentProfile)
if choice == "" {
choice = strings.TrimSpace(cfg.PrimaryProfile)
}
if choice == "" {
choice = cfg.Profiles[0].CorpID
}
return runProfileSwitchTUI(cmd, cfg, choice)
}
func runProfileSwitchTUI(cmd *cobra.Command, cfg *authpkg.ProfilesConfig, selectedCorpID string) (string, error) {
model := newProfileSwitchTUIModel(cfg, selectedCorpID)
program := tea.NewProgram(
model,
tea.WithAltScreen(),
tea.WithInput(cmd.InOrStdin()),
tea.WithOutput(cmd.ErrOrStderr()),
tea.WithContext(cmd.Context()),
)
finalModel, err := program.Run()
if err != nil {
if errors.Is(err, tea.ErrInterrupted) {
return "", apperrors.NewValidation("组织选择中止: user aborted")
}
return "", apperrors.NewInternal(fmt.Sprintf("failed to run profile selector: %v", err))
}
final, ok := finalModel.(profileSwitchTUIModel)
if !ok || final.aborted || !final.submitted {
return "", apperrors.NewValidation("组织选择中止: user aborted")
}
return final.selectedCorpID(), nil
}
type profileSwitchTUIModel struct {
cfg *authpkg.ProfilesConfig
profiles []authpkg.Profile
selected int
offset int
submitted bool
aborted bool
}
func newProfileSwitchTUIModel(cfg *authpkg.ProfilesConfig, selectedCorpID string) profileSwitchTUIModel {
model := profileSwitchTUIModel{cfg: cfg}
if cfg != nil {
model.profiles = profileSwitchSortedProfiles(cfg.Profiles)
}
model.selected = profileSwitchProfileIndex(model.profiles, selectedCorpID)
if model.selected < 0 {
model.selected = 0
}
model.ensureSelectedVisible()
return model
}
func profileSwitchSortedProfiles(profiles []authpkg.Profile) []authpkg.Profile {
sorted := append([]authpkg.Profile(nil), profiles...)
sort.SliceStable(sorted, func(i, j int) bool {
left, leftOK := profileSwitchSortTime(sorted[i])
right, rightOK := profileSwitchSortTime(sorted[j])
if leftOK && rightOK && !left.Equal(right) {
return left.After(right)
}
if leftOK != rightOK {
return leftOK
}
return false
})
return sorted
}
func profileSwitchSortTime(p authpkg.Profile) (time.Time, bool) {
for _, raw := range []string{p.LastLoginAt, p.UpdatedAt, p.LastUsedAt} {
if t, ok := parseProfileSwitchTime(raw); ok {
return t, true
}
}
return time.Time{}, false
}
func parseProfileSwitchTime(raw string) (time.Time, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, false
}
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}, false
}
return t, true
}
func (m profileSwitchTUIModel) Init() tea.Cmd {
return nil
}
func (m profileSwitchTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc", "q":
m.aborted = true
return m, tea.Quit
case "up", "k":
if m.selected > 0 {
m.selected--
m.ensureSelectedVisible()
}
case "down", "j":
if m.selected < len(m.profiles)-1 {
m.selected++
m.ensureSelectedVisible()
}
case "enter":
m.submitted = true
return m, tea.Quit
}
}
return m, nil
}
func (m profileSwitchTUIModel) View() string {
var b strings.Builder
title := profileSwitchTitleStyle().Render("选择要切换的组织")
hint := profileSwitchMutedStyle().Render("全部已登录 profile,↑↓ 选择,Enter 确认")
b.WriteString(title)
b.WriteString("\n")
b.WriteString(hint)
b.WriteString("\n\n")
b.WriteString(m.tableView())
b.WriteString("\n")
b.WriteString(profileSwitchMutedStyle().Render("↑/k up • ↓/j down • enter submit • esc cancel"))
return b.String()
}
func (m profileSwitchTUIModel) tableView() string {
rows := []string{
profileSwitchBorder("┌", "┬", "┐"),
profileSwitchStyledTableLine("组织名", "本地状态", profileSwitchHeaderStyle()),
profileSwitchBorder("├", "┼", "┤"),
}
for i := 0; i < profileSwitchVisibleOptions; i++ {
idx := m.offset + i
if idx >= 0 && idx < len(m.profiles) {
rows = append(rows, m.profileRow(idx))
continue
}
rows = append(rows, profileSwitchStyledTableLine("", "", profileSwitchNormalRowStyle()))
}
rows = append(rows, profileSwitchBorder("└", "┴", "┘"))
return strings.Join(rows, "\n")
}
func (m profileSwitchTUIModel) profileRow(idx int) string {
profile := m.profiles[idx]
org, status := profileSwitchProfileCells(profile, m.cfg)
style := profileSwitchNormalRowStyle()
if idx == m.selected {
org = "› " + org
style = profileSwitchSelectedRowStyle()
} else {
org = " " + org
}
return profileSwitchStyledTableLine(org, status, style)
}
func (m *profileSwitchTUIModel) ensureSelectedVisible() {
if len(m.profiles) == 0 {
m.selected = 0
m.offset = 0
return
}
if m.selected < 0 {
m.selected = 0
}
if m.selected >= len(m.profiles) {
m.selected = len(m.profiles) - 1
}
if m.selected < m.offset {
m.offset = m.selected
}
if m.selected >= m.offset+profileSwitchVisibleOptions {
m.offset = m.selected - profileSwitchVisibleOptions + 1
}
maxOffset := len(m.profiles) - profileSwitchVisibleOptions
if maxOffset < 0 {
maxOffset = 0
}
if m.offset > maxOffset {
m.offset = maxOffset
}
if m.offset < 0 {
m.offset = 0
}
}
func (m profileSwitchTUIModel) selectedCorpID() string {
if m.selected < 0 || m.selected >= len(m.profiles) {
return ""
}
return strings.TrimSpace(m.profiles[m.selected].CorpID)
}
func profileSwitchProfileIndex(profiles []authpkg.Profile, corpID string) int {
corpID = strings.TrimSpace(corpID)
for i, p := range profiles {
if strings.TrimSpace(p.CorpID) == corpID {
return i
}
}
return -1
}
func profileSwitchOptionLabel(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
org, status := profileSwitchProfileCells(p, cfg)
if status == "" {
return org
}
return strings.Join([]string{org, status}, " | ")
}
func profileSwitchProfileCells(p authpkg.Profile, cfg *authpkg.ProfilesConfig) (string, string) {
return profileOrgName(p), profileSwitchProfileStatus(p, cfg)
}
func profileSwitchProfileStatus(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
if cfg != nil && p.CorpID == cfg.CurrentProfile {
return "当前组织"
}
return ""
}
func profileSwitchBorder(left, sep, right string) string {
segments := []string{
strings.Repeat("─", profileSwitchCellWidth(profileSwitchOrgWidth)),
strings.Repeat("─", profileSwitchCellWidth(profileSwitchStatusWidth)),
}
return profileSwitchBorderStyle().Render(left + strings.Join(segments, sep) + right)
}
func profileSwitchTableLine(org, status string) string {
cells := []string{
profileSwitchTableCell(org, profileSwitchOrgWidth),
profileSwitchTableCell(status, profileSwitchStatusWidth),
}
return "│" + strings.Join(cells, "│") + "│"
}
func profileSwitchStyledTableLine(org, status string, style lipgloss.Style) string {
cells := []string{
style.Render(profileSwitchTableCell(org, profileSwitchOrgWidth)),
style.Render(profileSwitchTableCell(status, profileSwitchStatusWidth)),
}
return profileSwitchTableSeparator() + strings.Join(cells, profileSwitchTableSeparator()) + profileSwitchTableSeparator()
}
func profileSwitchTableSeparator() string {
return profileSwitchBorderStyle().Render("│")
}
func profileSwitchTableCell(value string, width int) string {
clipped := clipProfileDisplayCell(strings.TrimSpace(value), width)
padding := strings.Repeat(" ", profileSwitchCellPadding)
return padding + padProfileDisplayCell(clipped, width) + padding
}
func padProfileDisplayCell(value string, width int) string {
padding := width - lipgloss.Width(value)
if padding < 0 {
padding = 0
}
return value + strings.Repeat(" ", padding)
}
func profileSwitchCellWidth(contentWidth int) int {
return contentWidth + profileSwitchCellPadding*2
}
func profileSwitchSelectedRowStyle() lipgloss.Style {
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#69B1FF")).Bold(true)
}
func profileSwitchNormalRowStyle() lipgloss.Style {
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#FFFFFF"))
}
func profileSwitchHeaderStyle() lipgloss.Style {
return profileSwitchMutedStyle().Bold(true)
}
func profileSwitchBorderStyle() lipgloss.Style {
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#2F3B52"))
}
func profileSwitchTitleStyle() lipgloss.Style {
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#69B1FF")).Bold(true)
}
func profileSwitchMutedStyle() lipgloss.Style {
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#8A96A8"))
}
type profileListResponse struct {
Success bool `json:"success"`
PrimaryProfile string `json:"primaryProfile,omitempty"`
CurrentProfile string `json:"currentProfile,omitempty"`
PreviousProfile string `json:"previousProfile,omitempty"`
Profiles []profileView `json:"profiles"`
}
type profileUseResponse struct {
Success bool `json:"success"`
Profile profileView `json:"profile"`
}
type profileView struct {
CorpID string `json:"corpId"`
CorpName string `json:"corpName"`
UserID string `json:"userId,omitempty"`
UserName string `json:"userName,omitempty"`
ClientID string `json:"clientId,omitempty"`
Status string `json:"status,omitempty"`
AuthorizedDomains []string `json:"authorizedDomains,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
RefreshExpAt string `json:"refreshExpAt,omitempty"`
LastLoginAt string `json:"lastLoginAt,omitempty"`
LastUsedAt string `json:"lastUsedAt,omitempty"`
IsPrimary bool `json:"isPrimary"`
IsCurrent bool `json:"isCurrent"`
}
func writeProfileListJSON(w io.Writer, cfg *authpkg.ProfilesConfig) error {
resp := profileListResponse{
Success: true,
PrimaryProfile: cfg.PrimaryProfile,
CurrentProfile: cfg.CurrentProfile,
PreviousProfile: cfg.PreviousProfile,
Profiles: profileViews(cfg),
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
func writeProfileUseJSON(w io.Writer, profile *authpkg.Profile, cfg *authpkg.ProfilesConfig) error {
resp := profileUseResponse{Success: true}
if profile != nil {
primaryProfile := ""
currentProfile := ""
if cfg != nil {
primaryProfile = cfg.PrimaryProfile
currentProfile = cfg.CurrentProfile
}
resp.Profile = profileViewFromProfile(*profile, primaryProfile, currentProfile)
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
func writeProfileListTable(w io.Writer, cfg *authpkg.ProfilesConfig) {
if cfg == nil || len(cfg.Profiles) == 0 {
fmt.Fprintln(w, "未找到已登录 profile")
return
}
fmt.Fprintf(w, "%-3s %-3s %-28s %-34s %-10s %s\n", "CUR", "PRI", "ORG_NAME", "CORP_ID", "STATUS", "USER")
for _, p := range cfg.Profiles {
current := ""
if p.CorpID == cfg.CurrentProfile {
current = "*"
}
primary := ""
if p.CorpID == cfg.PrimaryProfile {
primary = "*"
}
user := p.UserName
if user == "" {
user = p.UserID
}
status := p.Status
if status == "" {
status = authpkg.ProfileStatusActive
}
fmt.Fprintf(
w,
"%-3s %-3s %-28s %-34s %-10s %s\n",
current,
primary,
clipProfileCell(profileOrgName(p), 28),
clipProfileCell(p.CorpID, 34),
status,
user,
)
}
}
func profileUseMessage(profile *authpkg.Profile) string {
if profile == nil {
return "[OK] 当前 profile 已切换"
}
corpID := strings.TrimSpace(profile.CorpID)
orgName := strings.TrimSpace(profile.CorpName)
if orgName == "" {
orgName = profileOrgName(*profile)
}
return fmt.Sprintf("[OK] 当前组织: %s (%s)", orgName, corpID)
}
func profileOrgName(p authpkg.Profile) string {
if v := strings.TrimSpace(p.CorpName); v != "" {
return v
}
if v := strings.TrimSpace(p.Name); v != "" {
return v
}
return strings.TrimSpace(p.CorpID)
}
func profileViews(cfg *authpkg.ProfilesConfig) []profileView {
if cfg == nil {
return nil
}
views := make([]profileView, 0, len(cfg.Profiles))
for _, p := range cfg.Profiles {
views = append(views, profileViewFromProfile(p, cfg.PrimaryProfile, cfg.CurrentProfile))
}
return views
}
func profileViewFromProfile(p authpkg.Profile, primaryProfile, currentProfile string) profileView {
return profileView{
CorpID: p.CorpID,
CorpName: profileOrgName(p),
UserID: p.UserID,
UserName: p.UserName,
ClientID: p.ClientID,
Status: p.Status,
AuthorizedDomains: p.AuthorizedDomains,
ExpiresAt: p.ExpiresAt,
RefreshExpAt: p.RefreshExpAt,
LastLoginAt: p.LastLoginAt,
LastUsedAt: p.LastUsedAt,
IsPrimary: p.CorpID == primaryProfile,
IsCurrent: p.CorpID == currentProfile,
}
}
func clipProfileCell(value string, limit int) string {
if limit <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= limit {
return value
}
if limit <= 3 {
return string(runes[:limit])
}
return string(runes[:limit-3]) + "..."
}
func clipProfileDisplayCell(value string, limit int) string {
if limit <= 0 {
return ""
}
if lipgloss.Width(value) <= limit {
return value
}
if limit <= 3 {
var b strings.Builder
for _, r := range value {
rw := lipgloss.Width(string(r))
if lipgloss.Width(b.String())+rw > limit {
break
}
b.WriteRune(r)
}
return b.String()
}
target := limit - 3
var b strings.Builder
width := 0
for _, r := range value {
rw := lipgloss.Width(string(r))
if width+rw > target {
break
}
b.WriteRune(r)
width += rw
}
return b.String() + "..."
}
+582
View File
@@ -0,0 +1,582 @@
// 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"
"encoding/json"
"fmt"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
)
func TestWriteProfileUseJSONKeepsPrimaryAndCurrentDistinct(t *testing.T) {
profile := &authpkg.Profile{
Name: "B Org",
CorpID: "corp_b",
CorpName: "B Org",
Status: authpkg.ProfileStatusActive,
}
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "corp_a",
CurrentProfile: "corp_b",
}
var buf bytes.Buffer
if err := writeProfileUseJSON(&buf, profile, cfg); err != nil {
t.Fatalf("writeProfileUseJSON() error = %v", err)
}
var resp profileUseResponse
if err := json.Unmarshal(buf.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if bytes.Contains(buf.Bytes(), []byte(`"name"`)) {
t.Fatalf("profile use JSON should not contain name when corpName is present:\n%s", buf.String())
}
if resp.Profile.CorpName != "B Org" {
t.Fatalf("corpName = %q, want B Org", resp.Profile.CorpName)
}
if !resp.Profile.IsCurrent {
t.Fatalf("isCurrent = false, want true")
}
if resp.Profile.IsPrimary {
t.Fatalf("isPrimary = true, want false")
}
}
func TestProfileListRootCommandJSONIncludesCorpName(t *testing.T) {
setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "json", "profile", "list"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile list --format json error = %v\noutput:\n%s", err, out.String())
}
var resp profileListResponse
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String())
}
if !resp.Success {
t.Fatal("success = false, want true")
}
if resp.PrimaryProfile != "corp_primary" || resp.CurrentProfile != "corp_secondary" || resp.PreviousProfile != "corp_primary" {
t.Fatalf("profile pointers = primary %q current %q previous %q, want corp_primary/corp_secondary/corp_primary", resp.PrimaryProfile, resp.CurrentProfile, resp.PreviousProfile)
}
if len(resp.Profiles) != 2 {
t.Fatalf("profiles len = %d, want 2", len(resp.Profiles))
}
if bytes.Contains(out.Bytes(), []byte(`"name"`)) {
t.Fatalf("profile list JSON should not contain name when corpName is present:\n%s", out.String())
}
for _, p := range resp.Profiles {
if p.CorpName == "" {
t.Fatalf("profile %s missing corpName in JSON response: %#v", p.CorpID, p)
}
}
}
func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "profile", "use", "corp_primary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use corp_primary error = %v\noutput:\n%s", err, out.String())
}
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
t.Fatalf("profile use output should include organization name:\n%s", out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" {
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile)
}
legacyToken, err := authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if legacyToken.CorpID != "corp_primary" {
t.Fatalf("legacy token corp = %q, want corp_primary", legacyToken.CorpID)
}
cmd = NewRootCommand()
out.Reset()
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "profile", "use", "-"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use - error = %v\noutput:\n%s", err, out.String())
}
if !bytes.Contains(out.Bytes(), []byte("组织: corp_secondary org")) {
t.Fatalf("profile use - output should include organization name:\n%s", out.String())
}
cfg, err = authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_secondary" || cfg.PreviousProfile != "corp_primary" {
t.Fatalf("profile pointers = current %q previous %q, want corp_secondary/corp_primary", cfg.CurrentProfile, cfg.PreviousProfile)
}
legacyToken, err = authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if legacyToken.CorpID != "corp_secondary" {
t.Fatalf("legacy token corp = %q, want corp_secondary", legacyToken.CorpID)
}
}
func TestProfileSwitchRootCommandSwitchesPrimaryOrganizationAndLegacyMirror(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "profile", "switch", "corp_primary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile switch corp_primary error = %v\noutput:\n%s", err, out.String())
}
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
t.Fatalf("profile switch output should include organization name:\n%s", out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" {
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile)
}
legacyToken, err := authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if legacyToken.CorpID != "corp_primary" {
t.Fatalf("legacy token corp = %q, want corp_primary", legacyToken.CorpID)
}
}
func TestProfileSwitchRootCommandSupportsCorpIDFlag(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "profile", "switch", "--corpId", "corp_primary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile switch --corpId error = %v\noutput:\n%s", err, out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" {
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
}
cmd = NewRootCommand()
out.Reset()
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "profile", "use", "--corp", "corp_secondary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use --corp error = %v\noutput:\n%s", err, out.String())
}
cfg, err = authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_secondary" {
t.Fatalf("currentProfile = %q, want corp_secondary", cfg.CurrentProfile)
}
}
func TestProfileSwitchRootCommandRejectsConflictingSelectors(t *testing.T) {
setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"profile", "switch", "corp_primary", "--corpId", "corp_secondary"})
err := cmd.Execute()
if err == nil {
t.Fatalf("profile switch with conflicting selectors succeeded\noutput:\n%s", out.String())
}
if !strings.Contains(err.Error(), "只能指定一个组织选择器") {
t.Fatalf("error = %v, want conflicting selector validation", err)
}
}
func TestProfileSwitchNoArgsUsesTUISelector(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
oldSelector := profileSwitchSelector
t.Cleanup(func() {
profileSwitchSelector = oldSelector
})
called := false
profileSwitchSelector = func(cmd *cobra.Command, gotConfigDir string) (string, error) {
called = true
if gotConfigDir != configDir {
t.Fatalf("configDir = %q, want %q", gotConfigDir, configDir)
}
return "corp_primary", nil
}
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"profile", "switch"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile switch error = %v\noutput:\n%s", err, out.String())
}
if !called {
t.Fatal("profile switch without args did not invoke TUI selector")
}
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
t.Fatalf("profile switch TUI path should use human output by default:\n%s", out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" {
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
}
}
func TestProfileSwitchOptionLabelUsesOnlyOrganizationAndCurrentState(t *testing.T) {
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "corp_primary",
CurrentProfile: "corp_secondary",
Profiles: []authpkg.Profile{
{
CorpID: "corp_primary",
CorpName: "第一组织",
UserName: "alice",
Status: authpkg.ProfileStatusActive,
},
{
CorpID: "corp_secondary",
CorpName: "第二组织",
UserName: "bob",
Status: authpkg.ProfileStatusActive,
},
},
}
primary := profileSwitchOptionLabel(cfg.Profiles[0], cfg)
current := profileSwitchOptionLabel(cfg.Profiles[1], cfg)
for _, label := range []string{primary, current} {
if strings.Contains(label, "\n") {
t.Fatalf("profile switch label contains newline: %q", label)
}
}
if !strings.Contains(primary, "第一组织") {
t.Fatalf("primary option missing organization name: %q", primary)
}
if !strings.Contains(current, "当前组织") {
t.Fatalf("current option missing current marker: %q", current)
}
for _, unwanted := range []string{"alice", "bob", "已登录", "主组织", "corp_primary", "corp_secondary"} {
if strings.Contains(primary, unwanted) || strings.Contains(current, unwanted) {
t.Fatalf("profile switch option should not contain %q: %q / %q", unwanted, primary, current)
}
}
}
func TestProfileSwitchTUIViewUsesFixedOuterTable(t *testing.T) {
cfg := profileSwitchTestConfig(2)
model := newProfileSwitchTUIModel(cfg, "corp_00")
view := model.tableView()
if lines := strings.Split(view, "\n"); len(lines) != profileSwitchVisibleOptions+4 {
t.Fatalf("table line count = %d, want %d:\n%s", len(lines), profileSwitchVisibleOptions+4, view)
}
for _, want := range []string{"┌", "┬", "┐", "├", "┼", "┤", "└", "┴", "┘", "组织名", "本地状态"} {
if !strings.Contains(view, want) {
t.Fatalf("profile switch table missing %q in:\n%s", want, view)
}
}
for _, unwanted := range []string{"CORP_ID", "ORGANIZATION", "STATUS"} {
if strings.Contains(view, unwanted) {
t.Fatalf("profile switch table should not contain %q:\n%s", unwanted, view)
}
}
if got := strings.Count(view, "│"); got != (profileSwitchVisibleOptions+1)*3 {
t.Fatalf("table vertical separators = %d, want %d\n%s", got, (profileSwitchVisibleOptions+1)*3, view)
}
for _, profile := range cfg.Profiles {
if got := strings.Count(view, profile.CorpID); got != 0 {
t.Fatalf("profile corpId %s appears %d times, want hidden:\n%s", profile.CorpID, got, view)
}
}
}
func TestProfileSwitchTUISortsLatestLoggedInProfilesFirst(t *testing.T) {
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "old",
CurrentProfile: "old",
Profiles: []authpkg.Profile{
{CorpID: "old", CorpName: "旧组织", LastLoginAt: "2026-06-26T10:00:00+08:00"},
{CorpID: "new", CorpName: "新组织", LastLoginAt: "2026-06-26T12:00:00+08:00"},
{CorpID: "fallback", CorpName: "兜底组织", UpdatedAt: "2026-06-26T11:00:00+08:00"},
},
}
model := newProfileSwitchTUIModel(cfg, "old")
gotOrder := []string{model.profiles[0].CorpID, model.profiles[1].CorpID, model.profiles[2].CorpID}
wantOrder := []string{"new", "fallback", "old"}
if strings.Join(gotOrder, ",") != strings.Join(wantOrder, ",") {
t.Fatalf("profile order = %v, want %v", gotOrder, wantOrder)
}
if got := model.selectedCorpID(); got != "old" {
t.Fatalf("selectedCorpID = %q, want old", got)
}
}
func TestProfileSwitchTUIArrowKeysMoveSelectionWithoutDuplicatingRows(t *testing.T) {
cfg := profileSwitchTestConfig(7)
model := newProfileSwitchTUIModel(cfg, "corp_00")
for step := 0; step < 6; step++ {
view := model.tableView()
if got := strings.Count(view, "›"); got != 1 {
t.Fatalf("step %d selected cursor count = %d, want 1:\n%s", step, got, view)
}
for _, profile := range cfg.Profiles {
name := profileOrgName(profile)
if got := strings.Count(view, name); got > 1 {
t.Fatalf("step %d profile %s appears %d times, want at most once:\n%s", step, name, got, view)
}
}
next, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown})
model = next.(profileSwitchTUIModel)
}
if model.selected != 6 || model.offset != 2 {
t.Fatalf("selection after down keys = selected %d offset %d, want 6/2", model.selected, model.offset)
}
}
func TestProfileSwitchTableRowsKeepFixedDisplayWidth(t *testing.T) {
rows := []string{
profileSwitchTableLine("组织名", "本地状态"),
profileSwitchTableLine("› 钉钉(中国)信息技术有限公司", "当前组织"),
profileSwitchTableLine(" ACME", ""),
profileSwitchTableLine("", ""),
profileSwitchStyledTableLine("组织名", "本地状态", profileSwitchHeaderStyle()),
profileSwitchStyledTableLine("› 钉钉(中国)信息技术有限公司", "当前组织", profileSwitchSelectedRowStyle()),
profileSwitchStyledTableLine(" ACME", "", profileSwitchNormalRowStyle()),
profileSwitchStyledTableLine("", "", profileSwitchNormalRowStyle()),
}
wantWidth := lipgloss.Width(rows[0])
for i, row := range rows {
if got := lipgloss.Width(row); got != wantWidth {
t.Fatalf("row[%d] width = %d, want %d: %q", i, got, wantWidth, row)
}
if got := strings.Count(row, "│"); got != 3 {
t.Fatalf("row[%d] separator count = %d, want 3: %q", i, got, row)
}
}
}
func TestProfileSwitchOptionLabelHidesCorpID(t *testing.T) {
const corpID = "ding8196cd9a2b2405da24f2f5cc6abecb85"
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: corpID,
CurrentProfile: corpID,
}
label := profileSwitchOptionLabel(authpkg.Profile{
CorpID: corpID,
CorpName: "钉钉",
}, cfg)
for _, want := range []string{"钉钉", "当前组织"} {
if !strings.Contains(label, want) {
t.Fatalf("profile switch label missing %q in %q", want, label)
}
}
for _, unwanted := range []string{"ding8196", "cb85", "主组织"} {
if strings.Contains(label, unwanted) {
t.Fatalf("profile switch label should not contain %q in %q", unwanted, label)
}
}
}
func profileSwitchTestConfig(count int) *authpkg.ProfilesConfig {
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "corp_00",
CurrentProfile: "corp_00",
}
for i := 0; i < count; i++ {
corpID := fmt.Sprintf("corp_%02d", i)
cfg.Profiles = append(cfg.Profiles, authpkg.Profile{
CorpID: corpID,
CorpName: fmt.Sprintf("组织%02d", i),
Status: authpkg.ProfileStatusActive,
})
}
return cfg
}
func TestAuthCommandDoesNotExposeSwitch(t *testing.T) {
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"auth", "switch"})
err := cmd.Execute()
if err == nil {
t.Fatalf("auth switch succeeded, want unknown command error\noutput:\n%s", out.String())
}
if !strings.Contains(err.Error(), `unknown command "switch" for "dws auth"`) {
t.Fatalf("error = %v, want auth switch unknown command", err)
}
}
func TestProfileUseNoArgsUsesTUISelector(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
oldSelector := profileSwitchSelector
t.Cleanup(func() {
profileSwitchSelector = oldSelector
})
profileSwitchSelector = func(cmd *cobra.Command, gotConfigDir string) (string, error) {
if gotConfigDir != configDir {
t.Fatalf("configDir = %q, want %q", gotConfigDir, configDir)
}
return "corp_primary", nil
}
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"profile", "use"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use error = %v\noutput:\n%s", err, out.String())
}
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
t.Fatalf("profile use TUI path should use human output by default:\n%s", out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" {
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
}
}
func TestProfileSwitchSelectorRequiresInteractiveTerminal(t *testing.T) {
oldInteractive := profileSwitchInteractiveTerminal
t.Cleanup(func() {
profileSwitchInteractiveTerminal = oldInteractive
})
profileSwitchInteractiveTerminal = func() bool { return false }
_, err := selectProfileSwitchProfile(nil, t.TempDir())
if err == nil {
t.Fatal("selectProfileSwitchProfile() succeeded, want validation error")
}
if !bytes.Contains([]byte(err.Error()), []byte("profile selector required")) {
t.Fatalf("error = %v, want profile selector hint", err)
}
}
func TestWriteProfileListTableIncludesCorpName(t *testing.T) {
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "corp_a",
CurrentProfile: "corp_b",
Profiles: []authpkg.Profile{
{
Name: "DingTalk China",
CorpID: "corp_a",
CorpName: "钉钉(中国)信息技术有限公司",
UserName: "alice",
Status: authpkg.ProfileStatusActive,
},
{
Name: "B Org",
CorpID: "corp_b",
CorpName: "B 组织",
UserID: "bob-id",
},
},
}
var buf bytes.Buffer
writeProfileListTable(&buf, cfg)
out := buf.String()
for _, want := range []string{
"ORG_NAME",
"钉钉(中国)信息技术有限公司",
"B 组织",
"corp_a",
"corp_b",
} {
if !bytes.Contains(buf.Bytes(), []byte(want)) {
t.Fatalf("profile list table missing %q in output:\n%s", want, out)
}
}
for _, unwanted := range []string{"PROFILE", "DingTalk China"} {
if bytes.Contains(buf.Bytes(), []byte(unwanted)) {
t.Fatalf("profile list table should not contain %q in output:\n%s", unwanted, out)
}
}
}
func TestProfileUseMessageIncludesCorpName(t *testing.T) {
got := profileUseMessage(&authpkg.Profile{
Name: "DingTalk China",
CorpID: "ding8196",
CorpName: "钉钉(中国)信息技术有限公司",
})
for _, want := range []string{"当前组织: 钉钉(中国)信息技术有限公司", "ding8196"} {
if !bytes.Contains([]byte(got), []byte(want)) {
t.Fatalf("profileUseMessage() missing %q in %q", want, got)
}
}
if bytes.Contains([]byte(got), []byte("DingTalk China")) {
t.Fatalf("profileUseMessage() should not include profile name when corpName is present: %q", got)
}
}
@@ -0,0 +1,158 @@
package app
import (
"bytes"
"context"
"sync"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"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/spf13/cobra"
)
func TestProductCommandsAcceptGlobalProfileFlag(t *testing.T) {
const selectedProfile = "corp_profile_matrix"
products := []struct {
name string
path []string
tool string
}{
{name: "aitable", path: []string{"aitable", "profile-test", "probe"}, tool: "aitable_profile_probe"},
{name: "attendance", path: []string{"attendance", "profile-test", "probe"}, tool: "attendance_profile_probe"},
{name: "calendar", path: []string{"calendar", "profile-test", "probe"}, tool: "calendar_profile_probe"},
{name: "contact", path: []string{"contact", "profile-test", "probe"}, tool: "contact_profile_probe"},
{name: "devdoc", path: []string{"devdoc", "profile-test", "probe"}, tool: "devdoc_profile_probe"},
{name: "ding", path: []string{"ding", "profile-test", "probe"}, tool: "ding_profile_probe"},
{name: "report", path: []string{"report", "profile-test", "probe"}, tool: "report_profile_probe"},
{name: "todo", path: []string{"todo", "profile-test", "probe"}, tool: "todo_profile_probe"},
}
descriptors := make([]market.ServerDescriptor, 0, len(products))
for _, product := range products {
descriptors = append(descriptors, profileFlagProductDescriptor(product.name, product.tool))
}
capture := &profileFlagRunner{}
oldLoadDynamicCommands := loadDynamicCommandsFn
loadDynamicCommandsFn = func(_ context.Context, _ executor.Runner) []*cobra.Command {
SetDynamicServers(descriptors)
return compat.BuildDynamicCommands(descriptors, capture, nil, nil)
}
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
t.Cleanup(func() {
loadDynamicCommandsFn = oldLoadDynamicCommands
SetDynamicServers(nil)
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
})
for _, product := range products {
t.Run(product.name, func(t *testing.T) {
capture.reset()
authpkg.SetRuntimeProfile("")
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
args := append([]string{"-f", "json"}, product.path...)
args = append(args, "--profile", selectedProfile)
cmd.SetArgs(args)
// Arrange / Act: execute a product command with root --profile after the leaf.
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute(%v) error = %v\noutput:\n%s", args, err, out.String())
}
// Assert: the product tool runs under the selected profile without leaking it as a business arg.
call := capture.last()
if call == nil {
t.Fatal("expected product command to invoke runner")
}
if call.product != product.name {
t.Fatalf("canonical product = %q, want %q", call.product, product.name)
}
if call.tool != product.tool {
t.Fatalf("tool = %q, want %q", call.tool, product.tool)
}
if call.profile != selectedProfile {
t.Fatalf("runtime profile at execution = %q, want %q", call.profile, selectedProfile)
}
if _, ok := call.params["profile"]; ok {
t.Fatalf("--profile leaked into business params: %#v", call.params)
}
})
}
}
func profileFlagProductDescriptor(product, tool string) market.ServerDescriptor {
return market.ServerDescriptor{
Key: product,
DisplayName: product,
Endpoint: "https://example.invalid/" + product,
CLI: market.CLIOverlay{
ID: product,
Command: product,
Groups: map[string]market.CLIGroupDef{
"profile-test": {Description: "profile-test"},
},
ToolOverrides: map[string]market.CLIToolOverride{
tool: {
CLIName: "probe",
Group: "profile-test",
Description: tool,
RejectPositional: true,
},
},
},
}
}
type profileFlagCall struct {
product string
tool string
profile string
params map[string]any
}
type profileFlagRunner struct {
mu sync.Mutex
calls []profileFlagCall
}
func (r *profileFlagRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.mu.Lock()
defer r.mu.Unlock()
params := make(map[string]any, len(invocation.Params))
for key, value := range invocation.Params {
params[key] = value
}
r.calls = append(r.calls, profileFlagCall{
product: invocation.CanonicalProduct,
tool: invocation.Tool,
profile: authpkg.RuntimeProfile(),
params: params,
})
return executor.Result{Invocation: invocation}, nil
}
func (r *profileFlagRunner) reset() {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = nil
}
func (r *profileFlagRunner) last() *profileFlagCall {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.calls) == 0 {
return nil
}
call := r.calls[len(r.calls)-1]
return &call
}
+50
View File
@@ -0,0 +1,50 @@
// 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 (
"net/http"
"reflect"
"testing"
"time"
)
// TestIPv4HTTPClientHonoursHTTPProxyEnv guards the fix for #236 on the
// IPv4-forcing client used by the legacy registry / discovery path. The
// custom Transport overrides DialContext to force IPv4 — without an
// explicit Proxy field it would also drop env-var proxy support.
//
// We can't reliably invoke tr.Proxy(req) here because http.ProxyFromEnvironment
// memoises the env vars on first call (Go's envProxyOnce); ordering with other
// tests that read proxy env early would make this flaky. Asserting that the
// Transport's Proxy func points at http.ProxyFromEnvironment is sufficient to
// catch the regression — the runtime takes care of reading HTTP_PROXY/HTTPS_PROXY
// at process boot.
func TestIPv4HTTPClientHonoursHTTPProxyEnv(t *testing.T) {
t.Parallel()
client := ipv4HTTPClient(5 * time.Second)
tr, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("ipv4HTTPClient transport is %T, want *http.Transport", client.Transport)
}
if tr.Proxy == nil {
t.Fatal("ipv4HTTPClient transport.Proxy is nil — HTTP_PROXY env will be ignored (regression of #236)")
}
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
if gotPC != wantPC {
t.Errorf("ipv4HTTPClient transport.Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
}
}
+2 -2
View File
@@ -293,7 +293,7 @@ func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.
Status: "empty",
Request: &recovery.ToolCallRecord{
ServerID: "devdoc",
ToolName: "search_open_platform_docs",
ToolName: "search_open_platform_docs_rag",
Arguments: cloneRecoveryArgs(requestArgs),
},
},
@@ -302,7 +302,7 @@ func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.
retrieval.DocSearch.Status = "skipped"
return retrieval, nil
}
result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs", requestArgs)
result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs_rag", requestArgs)
if result != nil {
retrieval.DocSearch.Response = toRecoveryToolResponse(result)
}
+321 -98
View File
@@ -47,6 +47,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
@@ -66,6 +67,9 @@ func Execute() (exitCode int) {
}
}()
restoreArgs := normalizeProcessProfileArgs()
defer restoreArgs()
timing := NewTimingCollector()
defer func() {
StopAllStdioClients() // Ensure child processes are terminated on exit
@@ -95,6 +99,7 @@ func Execute() (exitCode int) {
if executed == nil {
executed = root
}
err = rewordRequiredFlagError(err)
if isUnknownCommandError(err) {
executed.SetOut(os.Stderr)
_ = executed.Help()
@@ -113,15 +118,55 @@ func isUnknownCommandError(err error) bool {
return err != nil && strings.Contains(err.Error(), "unknown command")
}
// rewordRequiredFlagError rewrites cobra's default missing-required-flag message
// (`required flag(s) "email" not set`) into the wukong-aligned form
// (`missing required flag(s): --email`). cobra's ValidateRequiredFlags returns
// this error directly (it does not pass through FlagErrorFunc), so it is
// normalised here. The substring "required flag" is preserved for compatibility
// with existing assertions; flag names gain the "--" prefix and quotes are
// dropped so error output matches hardcoded cmdutil.ValidateRequiredFlags.
func rewordRequiredFlagError(err error) error {
if err == nil {
return err
}
const pfx = "required flag(s) "
const sfx = " not set"
msg := err.Error()
if !strings.HasPrefix(msg, pfx) || !strings.HasSuffix(msg, sfx) {
return err
}
mid := strings.TrimSuffix(strings.TrimPrefix(msg, pfx), sfx)
var flags []string
for _, part := range strings.Split(mid, ", ") {
if name := strings.Trim(strings.TrimSpace(part), "\""); name != "" {
flags = append(flags, "--"+name)
}
}
if len(flags) == 0 {
return err
}
return apperrors.NewValidation(fmt.Sprintf("missing required flag(s): %s", strings.Join(flags, ", ")))
}
// flagErrorWithSuggestions provides helpful suggestions for common flag mistakes.
//
// 所有 flag 解析错误都会在 message 末尾追加 "See '<CommandPath> --help' for usage.",
// 与 docker / kubectl / gh / wukong CLI 的 UX 一致,方便用户/agent 复制完整命令查 help。
// 装在 root 的 FlagErrorFunc 通过 cobra 的 parent fallback 机制覆盖全命令树
// (cobra.Command.FlagErrorFunc 沿 c.parent 递归向上查找)。
func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
errMsg := err.Error()
// 尾部 hint:换行 + See '...' for usage.
// JSON 输出时 \n 会被序列化为字面 \n,文本输出时换行;
// 无论哪种格式,子串 "--help' for usage." 都可被检索到。
tail := fmt.Sprintf("\nSee '%s --help' for usage.", cmd.CommandPath())
msgWithTail := errMsg + tail
// Common flag aliases and suggestions
suggestions := map[string]string{
"--json": "提示: 请使用 --format json 或 -f json 来输出 JSON 格式",
"--method": "提示: dws auth login 默认使用 OAuth 设备流登录,无需指定 --method",
"--device-flow": "提示: dws auth login 默认已使用设备流,无需 --device-flow 参数",
"--method": "提示: dws auth login 默认使用 OAuth loopback 流;SSH/无头环境请加 --device 走设备流",
"--device-flow": "提示: 设备流的标志名是 --device(不是 --device-flow),SSH/无头环境登录请用 dws auth login --device",
"--email": "提示: dws 不支持邮箱/密码登录,请使用 dws auth login 进行扫码登录",
"--code": "提示: dws 不支持验证码登录,请使用 dws auth login 进行扫码登录",
"--corp-id": "提示: corp-id 会在登录时自动获取,无需手动指定",
@@ -133,11 +178,35 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
for flag, suggestion := range suggestions {
if strings.Contains(errMsg, "unknown flag: "+flag) {
return fmt.Errorf("%w\n%s", err, suggestion)
return apperrors.NewValidation(
msgWithTail,
apperrors.WithHint(suggestion),
apperrors.WithReason("unknown_flag"),
apperrors.WithCause(err),
apperrors.WithActions(fmt.Sprintf("Run '%s --help' for valid flags", cmd.CommandPath())),
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
)
}
}
return err
if strings.Contains(errMsg, "unknown flag:") {
fix := cmdutil.SuggestFlagFix(cmd, err)
if fix.Suggestion != "" {
return apperrors.NewValidation(
msgWithTail,
apperrors.WithHint(fix.Suggestion),
apperrors.WithReason("unknown_flag"),
apperrors.WithCause(err),
apperrors.WithActions(fmt.Sprintf("Run '%s --help' for valid flags", cmd.CommandPath())),
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
)
}
}
// Fallback:未命中已知别名 / SuggestFlagFix 未给建议的 flag 解析错误
// (missing required / ambiguous / unknown shorthand 等),仍包尾部 hint,
// 行为对齐 wukong / docker / kubectl。
return fmt.Errorf("%s%s", errMsg, tail)
}
func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err error) error {
@@ -232,6 +301,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
rootCtx = context.Background()
}
flags := &GlobalFlags{}
authpkg.SetRuntimeProfile(preparseProfileFlag(os.Args[1:]))
loader := cli.EnvironmentLoader{
LookupEnv: os.LookupEnv,
CatalogBaseURLOverride: DiscoveryBaseURL(),
@@ -245,6 +315,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
root := &cobra.Command{
Use: "dws",
Short: "DWS CLI",
Long: `提示: 如果遇到能力缺失、命令报错、新功能未注册、或无法完成任务, 请先用 'dws upgrade' 升级到最新版本后再试. 钉钉 OpenAPI 和 dws CLI 持续迭代, 新能力和 bugfix 会先在新版本上线.`,
Args: cobra.NoArgs,
SilenceErrors: true,
SilenceUsage: true,
@@ -254,6 +325,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
return cmd.Help()
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
authpkg.SetRuntimeProfile(flags.Profile)
// Apply OAuth credential overrides from CLI flags (highest priority).
if flags.ClientID != "" {
authpkg.SetClientID(flags.ClientID)
@@ -287,11 +359,15 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
genSkillsCmd.Hidden = true
mcpCmd := newMCPCommand(rootCtx, loader, runner, engine)
mcpCmd.Hidden = true
patCaller := newToolCallerAdapter(runner, flags)
utilityCommands := []*cobra.Command{
newAuthCommand(),
newAuthCommand(patCaller),
newProfileCommand(),
newAPICommand(flags),
newSkillCommand(),
newCacheCommand(),
newCatalogCommand(loader),
newConfigCommand(),
newDoctorCommand(),
newCompletionCommand(root),
@@ -317,7 +393,6 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
}
// PAT authorization commands (open-source core)
patCaller := newToolCallerAdapter(runner, flags)
pat.RegisterCommands(root, patCaller)
if fn := edition.Get().RegisterExtraCommands; fn != nil {
@@ -335,8 +410,87 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
return root
}
func newAuthCommand() *cobra.Command {
return buildAuthCommand()
func preparseProfileFlag(args []string) string {
args, _ = normalizeProfileFlagArgs(args)
for i := 0; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
switch {
case arg == "--profile" && i+1 < len(args):
return strings.TrimSpace(args[i+1])
case strings.HasPrefix(arg, "--profile="):
return strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
}
}
return ""
}
func normalizeProcessProfileArgs() func() {
original := append([]string(nil), os.Args...)
if len(os.Args) > 1 {
if normalized, changed := normalizeProfileFlagArgs(os.Args[1:]); changed {
os.Args = append([]string{os.Args[0]}, normalized...)
}
}
return func() {
os.Args = original
}
}
func normalizeProfileFlagArgs(args []string) ([]string, bool) {
if len(args) == 0 {
return args, false
}
out := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
arg := args[i]
trimmed := strings.TrimSpace(arg)
switch {
case trimmed == "--profile":
out = append(out, arg)
if i+1 >= len(args) {
continue
}
value, next := collectProfileFlagValue(args[i+1], args, i+2)
out = append(out, value)
i = next - 1
case strings.HasPrefix(trimmed, "--profile="):
value, next := collectProfileFlagValue(strings.TrimPrefix(trimmed, "--profile="), args, i+1)
out = append(out, "--profile="+value)
i = next - 1
default:
out = append(out, arg)
}
}
return out, argsChanged(args, out)
}
func collectProfileFlagValue(first string, args []string, next int) (string, int) {
parts := []string{strings.TrimSpace(first)}
for len(parts) > 0 && strings.HasSuffix(strings.TrimSpace(parts[len(parts)-1]), ",") && next < len(args) {
candidate := strings.TrimSpace(args[next])
if candidate == "" || strings.HasPrefix(candidate, "-") {
break
}
parts = append(parts, candidate)
next++
}
return strings.Join(parts, ""), next
}
func argsChanged(before, after []string) bool {
if len(before) != len(after) {
return true
}
for i := range before {
if before[i] != after[i] {
return true
}
}
return false
}
func newAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
return buildAuthCommand(patCaller)
}
func newSkillCommand() *cobra.Command {
@@ -420,20 +574,25 @@ func newCacheCommand() *cobra.Command {
if err != nil {
return apperrors.NewInternal("failed to read cache refresh flags")
}
baseURL := DiscoveryBaseURL()
store := cacheStoreFromEnv()
transportClient := transport.NewClient(nil)
transportClient.AuthToken = resolveRuntimeAuthToken(cmd.Context(), "")
// Market client here is only a fallback for Detail API calls inside
// DiscoverAllRuntime; the primary server-list fetch below goes
// through fetchRegistryServers so edition DiscoveryURL wins.
service := discovery.NewService(
market.NewClient(baseURL, nil),
market.NewClient(DiscoveryBaseURL(), nil),
transportClient,
store,
)
servers, err := service.DiscoverServers(cmd.Context())
resp, err := fetchRegistryServers(cmd.Context(), ipv4HTTPClient(config.HTTPTimeout))
if err != nil {
return err
return apperrors.NewDiscovery(fmt.Sprintf("cache refresh: fetch server list failed: %v", err))
}
servers := market.NormalizeServersForBaseURL(resp, "live_market", registryDiscoveryBaseURL())
_ = store.SaveRegistry(service.CachePartition(), cache.RegistrySnapshot{Servers: servers})
selected := selectServersForProduct(servers, product)
if strings.TrimSpace(product) != "" && len(selected) == 0 {
@@ -554,7 +713,7 @@ func newVersionCommand() *cobra.Command {
}
func newSchemaCommand(loader cli.CatalogLoader) *cobra.Command {
return cli.NewSchemaCommand(loader)
return cli.NewSchemaCommand(loader, newHelperToolFetcher())
}
func newGenerateSkillsCommand() *cobra.Command {
@@ -640,8 +799,76 @@ func newGenerateSkillsCommand() *cobra.Command {
return cmd
}
// buildMCPCommandFn is a test seam for newMCPCommand so a panic in the
// catalog-driven canonical build can be simulated without crafting a
// poisoned on-disk cache.
var buildMCPCommandFn = cli.NewMCPCommand
// newMCPCommand builds the canonical `dws mcp` tree, self-healing a poisoned
// cache when the build panics and degrading to an inert stub if that also
// fails.
//
// Why this guard exists: the canonical tree is assembled from cached catalog
// data BEFORE the legacy command build and before Cobra dispatches anything,
// so a panic here (e.g. a tool schema property named after the reserved
// --params flag, as cached during the 1.0.32 incident) used to abort every
// invocation — including `dws cache refresh` and `dws upgrade` — and was NOT
// covered by the legacy-path guards (#447/#452). Same two-staged recovery as
// buildEnvelopeCommandsSafe: quarantine the partition, retry once against a
// fresh fetch, then degrade with a `dws cache refresh` hint.
func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
return cli.NewMCPCommand(ctx, loader, runner, engine)
cmd, panicked := tryBuildMCPCommand(ctx, loader, runner, engine)
if panicked == nil {
return cmd
}
slog.Error("newMCPCommand: canonical command build panicked", "panic", panicked)
quarantined, qErr := cacheStoreFromEnv().QuarantinePartition(editionPartition())
if qErr != nil {
slog.Error("newMCPCommand: failed to quarantine discovery cache", "error", qErr)
}
if quarantined != "" {
fmt.Fprintf(os.Stderr,
"Warning: building canonical commands from the local discovery cache failed: %v\n"+
"The cached discovery data was moved to %s; rebuilding from a fresh fetch...\n",
panicked, quarantined)
cmd, panicked = tryBuildMCPCommand(ctx, loader, runner, engine)
if panicked == nil {
fmt.Fprintln(os.Stderr, "Canonical commands rebuilt successfully.")
return cmd
}
slog.Error("newMCPCommand: rebuild after cache quarantine panicked again, degrading to a stub", "panic", panicked)
}
fmt.Fprintf(os.Stderr,
"Warning: building canonical commands from the local discovery cache failed: %v\n"+
"The 'dws mcp' surface is temporarily unavailable; other commands still work.\n"+
"Run 'dws cache refresh' to rebuild the cache.\n", panicked)
buildErr := apperrors.NewInternal(fmt.Sprintf("canonical command build failed: %v; run 'dws cache refresh'", panicked))
stub := &cobra.Command{
Use: "mcp",
Short: "Canonical MCP-derived CLI surface (unavailable)",
Hidden: true,
Args: cobra.ArbitraryArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return buildErr
},
}
return stub
}
// tryBuildMCPCommand runs one attempt of the canonical build, converting a
// panic into a return value so the caller can decide between self-heal and
// degradation.
func tryBuildMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) (cmd *cobra.Command, panicked any) {
defer func() {
if r := recover(); r != nil {
cmd = nil
panicked = r
}
}()
return buildMCPCommandFn(ctx, loader, runner, engine), nil
}
// hideNonDirectRuntimeCommands marks top-level product commands as hidden
@@ -650,24 +877,17 @@ 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,
"completion": true,
"skill": true,
"plugin": true,
"profile": true,
"version": true,
"help": true,
"recovery": true,
@@ -693,8 +913,8 @@ 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,
"plugin": true, "skill": true, "cache": true,
"auth": true, "api": true, "login": true, "logout": true,
"plugin": true, "profile": true, "skill": true, "cache": true,
"config": true, "doctor": true, "completion": true,
"recovery": true, "upgrade": true, "version": true,
"schema": true, "mcp": true, "help": true,
@@ -1157,11 +1377,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)
@@ -1170,18 +1410,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...)
}
@@ -1337,8 +1589,10 @@ func buildHTTPCommandsFromTools(srv market.ServerDescriptor, tools []transport.T
}
}
// nil existingTools: single-server overlay built from a live tool list, so
// no phantom-leaf guard is needed (see BuildDynamicCommands doc).
return compat.BuildDynamicCommands(
[]market.ServerDescriptor{srv}, runner, detailsByID)
[]market.ServerDescriptor{srv}, runner, detailsByID, nil)
}
// deriveToolCLIName converts an MCP tool name (e.g. "web_search" or
@@ -1442,22 +1696,38 @@ func registerStdioServer(p *plugin.Plugin, sc plugin.StdioServerClient, runner e
}
// discoverStdioTools performs the blocking Initialize + ListTools handshake
// on a stdio MCP subprocess. Returns nil on any error (logged at Warn level).
// on a stdio MCP subprocess. Returns nil on any error (logged at Debug level).
// The default 2s budget comfortably accommodates Python/Node runtimes whose
// interpreter + dependency load dominates the first response. Operators with
// heavier startup chains can relax further via DWS_PLUGIN_COLD_TIMEOUT.
//
// A handshake failure here is an EXPECTED, benign outcome for an optional local
// plugin: e.g. the conference plugin reports "本地服务未就绪" whenever the
// DingTalk desktop client isn't running, which is the common case for anyone
// not actively recording a meeting. Discovery simply yields no tools and the
// run proceeds — commands that ship toolOverrides still register up-front via
// registerStdioServerFromOverlay (Phase A), so availability is unaffected.
//
// These run during command-tree construction (NewRootCommandWithEngine), which
// happens BEFORE PersistentPreRunE applies --debug/--verbose via
// configureLogLevel. So a Warn here printed to stderr on EVERY invocation
// regardless of flags, polluting output and misleading callers into treating it
// as the cause of an unrelated command error (e.g. an auth or PARAM_ERROR from a
// completely different server). Logging at Debug keeps the discovery miss out of
// normal output; surfacing it would require configuring the log level before the
// tree is built, which we deliberately avoid this close to release.
func discoverStdioTools(p *plugin.Plugin, sc plugin.StdioServerClient, timeouts pluginColdTimeouts) []transport.ToolDescriptor {
ctx, cancel := context.WithTimeout(context.Background(), timeouts.stdio)
defer cancel()
if _, err := sc.Client.Initialize(ctx); err != nil {
slog.Warn("plugin: stdio initialize failed",
slog.Debug("plugin: stdio initialize failed",
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
return nil
}
toolsResult, err := sc.Client.ListTools(ctx)
if err != nil {
slog.Warn("plugin: stdio ListTools failed",
slog.Debug("plugin: stdio ListTools failed",
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
return nil
}
@@ -1467,6 +1737,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",
@@ -1474,44 +1749,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{
@@ -1520,45 +1765,23 @@ 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)
// nil existingTools: overlay built from this plugin's live tool list.
cmds := compat.BuildDynamicCommands(
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
[]market.ServerDescriptor{descriptor}, runner, detailsByID, nil)
slog.Debug("plugin: stdio server registered",
"plugin", p.Manifest.Name, "server", sc.Key,
+94 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T) {
@@ -23,7 +24,7 @@ func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T)
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/cli/discovery/apis":
case "/cli/discovery/apis/cedar":
_ = json.NewEncoder(w).Encode(market.ListResponse{
Metadata: market.ListMetadata{Count: 2},
Servers: []market.ServerEnvelope{
@@ -106,6 +107,98 @@ func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T)
}
}
// TestCacheRefreshHonorsEditionDiscoveryURL asserts the `dws cache refresh`
// command routes its server-list fetch through edition.Hooks.DiscoveryURL /
// DiscoveryHeaders when they are set, instead of the default Market endpoint.
// Kept deliberately generic (no edition-specific strings) — concrete values
// belong to the overlay repo that installs the hooks, not to this open core.
func TestCacheRefreshHonorsEditionDiscoveryURL(t *testing.T) {
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
var (
editionHits atomic.Int32
marketHits atomic.Int32
gotHeaders atomic.Value // map[string]string
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/cli/edition/apis":
editionHits.Add(1)
snapshot := map[string]string{
"x-test-edition": r.Header.Get("x-test-edition"),
"x-test-client": r.Header.Get("x-test-client"),
}
gotHeaders.Store(snapshot)
_ = json.NewEncoder(w).Encode(market.ListResponse{
Metadata: market.ListMetadata{Count: 1},
Servers: []market.ServerEnvelope{
{
Server: market.RegistryServer{
Name: "Edition Service",
Remotes: []market.RegistryRemote{{Type: "streamable-http", URL: "https://example.invalid/mcp"}},
},
Meta: market.EnvelopeMeta{
Registry: market.RegistryMetadata{Status: "active"},
CLI: market.CLIOverlay{ID: "edition-service", Command: "edition-service"},
},
},
},
})
case "/cli/discovery/apis/cedar":
marketHits.Add(1)
http.Error(w, "market endpoint must not be called when edition DiscoveryURL is set", http.StatusNotFound)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
edition.Override(&edition.Hooks{
Name: "testing",
DiscoveryURL: srv.URL + "/cli/edition/apis",
DiscoveryHeaders: func() map[string]string {
return map[string]string{
"x-test-edition": "custom",
"x-test-client": "cli-refresh",
}
},
})
t.Cleanup(func() { edition.Override(&edition.Hooks{}) })
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmd := newCacheCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"refresh"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if got := editionHits.Load(); got != 1 {
t.Fatalf("edition DiscoveryURL hits = %d, want 1", got)
}
if got := marketHits.Load(); got != 0 {
t.Fatalf("market endpoint hits = %d, want 0 (edition DiscoveryURL must take precedence)", got)
}
headers, _ := gotHeaders.Load().(map[string]string)
if headers == nil {
t.Fatal("captured request headers = nil, want edition DiscoveryHeaders to be applied")
}
if headers["x-test-edition"] != "custom" {
t.Fatalf("x-test-edition header = %q, want %q", headers["x-test-edition"], "custom")
}
if headers["x-test-client"] != "cli-refresh" {
t.Fatalf("x-test-client header = %q, want %q", headers["x-test-client"], "cli-refresh")
}
}
func saveCLIIDDetail(t *testing.T, store *cache.Store, partition, cliID string) {
t.Helper()
+3 -3
View File
@@ -263,7 +263,7 @@ func TestRootHelpDoesNotRequirePINOrLogin(t *testing.T) {
if !strings.Contains(out.String(), "Discovered MCP Services:") {
t.Fatalf("root help output missing MCP summary:\n%s", out.String())
}
for _, want := range []string{"Utility Commands:", "skill", "auth", "version"} {
for _, want := range []string{"Utility Commands:", "skill", "auth", "profile", "version", "Global Flags:", "--profile"} {
if !strings.Contains(out.String(), want) {
t.Fatalf("root help output missing %q:\n%s", want, out.String())
}
@@ -351,8 +351,8 @@ func TestNestedShortHelpDoesNotRequirePINOrLogin(t *testing.T) {
if err := root.Execute(); err != nil {
t.Fatalf("Execute(devdoc article search -h) error = %v", err)
}
if !strings.Contains(out.String(), "devdoc/search") {
t.Fatalf("nested short help output missing command title:\n%s", out.String())
if !strings.Contains(out.String(), "搜索开放平台文档") || !strings.Contains(out.String(), "dws devdoc article search") {
t.Fatalf("nested short help output missing command help:\n%s", out.String())
}
}
+104 -26
View File
@@ -6,8 +6,10 @@ import (
"text/tabwriter"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func configureRootHelp(root *cobra.Command) {
@@ -50,38 +52,129 @@ func renderRootHelp(root *cobra.Command) {
utilities := visibleUtilityRootCommands(root)
w := root.OutOrStdout()
_, _ = fmt.Fprintln(w, tui.Header("Workspace CLI", "DingTalk blue-white technical console"))
_, _ = fmt.Fprintln(w, tui.Rule(76))
_, _ = fmt.Fprintln(w)
if len(services) == 0 {
_, _ = fmt.Fprintln(w, "No MCP services discovered.")
_, _ = fmt.Fprintf(w, "%s %s\n", tui.StateMark("warning"), tui.Warning("No MCP services discovered."))
_, _ = fmt.Fprintln(w)
} else {
_, _ = fmt.Fprintln(w, "Discovered MCP Services:")
_, _ = fmt.Fprintln(w, tui.Section("Discovered MCP Services:"))
_, _ = fmt.Fprintln(w)
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
for _, service := range services {
_, _ = fmt.Fprintf(tw, " %s\t%s\n", service.Name(), strings.TrimSpace(service.Short))
_, _ = fmt.Fprintf(tw, " %s %s\t%s\n", tui.StateMark("ok"), tui.Bold(service.Name()), tui.Dim(strings.TrimSpace(service.Short)))
}
_ = tw.Flush()
_, _ = fmt.Fprintln(w)
}
_, _ = fmt.Fprintln(w, "Usage:")
_, _ = fmt.Fprintln(w, " dws <service> [command] [flags]")
_, _ = fmt.Fprintln(w, tui.Section("Usage:"))
_, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.White("dws <service> [command] [flags]"))
if len(utilities) > 0 {
_, _ = fmt.Fprintln(w, " dws <command> [flags]")
_, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.White("dws <command> [flags]"))
}
_, _ = fmt.Fprintln(w)
if len(utilities) > 0 {
_, _ = fmt.Fprintln(w, "Utility Commands:")
_, _ = fmt.Fprintln(w, tui.Section("Utility Commands:"))
_, _ = fmt.Fprintln(w)
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
for _, utility := range utilities {
_, _ = fmt.Fprintf(tw, " %s\t%s\n", utility.Name(), strings.TrimSpace(utility.Short))
_, _ = fmt.Fprintf(tw, " %s %s\t%s\n", tui.Bullet(), tui.Bold(utility.Name()), tui.Dim(commandShort(utility)))
}
_ = tw.Flush()
_, _ = fmt.Fprintln(w)
}
_, _ = fmt.Fprintln(w, `Use "dws <service> --help" for more information about a discovered MCP service or "dws <command> --help" for utility commands.`)
renderRootGlobalFlags(root)
_, _ = fmt.Fprintf(w, "%s %s\n", tui.Key("Next"), `Use "dws <service> --help" for more information about a discovered MCP service or "dws <command> --help" for utility commands.`)
// Render root.Long after the command list so agents see the upgrade
// hint (or any other root-level guidance) after browsing all available
// commands and concluding none of them fit. Cobra's default help template
// would render Long automatically; the custom SetHelpFunc above replaces
// it and dropped this, so we restore it explicitly here.
if long := strings.TrimSpace(root.Long); long != "" {
_, _ = fmt.Fprintln(w)
_, _ = fmt.Fprintln(w, tui.Dim(long))
}
}
func renderRootGlobalFlags(root *cobra.Command) {
if root == nil {
return
}
flags := visiblePersistentFlags(root)
if len(flags) == 0 {
return
}
w := root.OutOrStdout()
_, _ = fmt.Fprintln(w, tui.Section("Global Flags:"))
_, _ = fmt.Fprintln(w)
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
for _, flag := range flags {
_, _ = fmt.Fprintf(tw, " %s\t%s\n", formatRootFlag(flag), tui.Dim(strings.TrimSpace(flag.Usage)))
}
_ = tw.Flush()
_, _ = fmt.Fprintln(w)
}
func visiblePersistentFlags(root *cobra.Command) []*pflag.Flag {
if root == nil {
return nil
}
flags := make([]*pflag.Flag, 0)
root.PersistentFlags().VisitAll(func(flag *pflag.Flag) {
if flag == nil || flag.Hidden {
return
}
flags = append(flags, flag)
})
return flags
}
func formatRootFlag(flag *pflag.Flag) string {
if flag == nil {
return ""
}
name := "--" + flag.Name
if flag.Value != nil && flag.Value.Type() != "bool" {
name += " " + flag.Value.Type()
}
if flag.Shorthand == "" {
return " " + name
}
return "-" + flag.Shorthand + ", " + name
}
func commandShort(cmd *cobra.Command) string {
if cmd == nil {
return ""
}
short := strings.TrimSpace(cmd.Short)
if cmd.Name() == "help" && short == "Help about any command" {
return i18n.T("查看任意命令的帮助信息")
}
return short
}
// 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 {
@@ -89,16 +182,7 @@ func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command {
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 +205,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() {
+385 -29
View File
@@ -17,6 +17,7 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -59,7 +60,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",
@@ -88,11 +89,34 @@ const (
envDingtalkTraceID = "DINGTALK_TRACE_ID"
envDingtalkSessionID = "DINGTALK_SESSION_ID"
envDingtalkMessageID = "DINGTALK_MESSAGE_ID"
envDWSSessionID = "DWS_SESSION_ID"
envRewindSessionID = "REWIND_SESSION_ID"
// Environment variables for third-party channel integration
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", authpkg.AgentCodeEnvPresent(),
)
})
}
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,9 +156,28 @@ 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()
selections, multi, err := resolveMultiProfileSelections(defaultConfigDir(), authpkg.RuntimeProfile())
if err != nil {
return executor.Result{}, apperrors.NewValidation(err.Error())
}
if multi {
return r.runMultiProfile(ctx, invocation, selections)
}
return r.runSingle(ctx, invocation, true)
}
func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invocation, prefetchToken bool) (executor.Result, error) {
if r.loader == nil || r.transport == nil {
return r.fallback.Run(ctx, invocation)
}
r.transport.ExtraHeaders = resolveIdentityHeaders()
// Mock mode: skip catalog validation, use a placeholder endpoint.
if r.globalFlags != nil && r.globalFlags.Mock {
@@ -148,7 +191,9 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
// Prefetch the Keychain token in the background. Keychain access costs
// ~70ms on macOS; starting it here lets the load overlap with endpoint
// resolution and catalog loading below.
go getCachedRuntimeToken(ctx)
if prefetchToken {
go getCachedRuntimeToken(ctx)
}
if shouldUseDirectRuntime(invocation) {
if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok {
@@ -168,10 +213,21 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
product, ok := catalog.FindProduct(invocation.CanonicalProduct)
if !ok || strings.TrimSpace(product.Endpoint) == "" {
return r.fallback.Run(ctx, invocation)
return r.handleCatalogMiss(ctx, invocation, "product missing from discovery catalog and no supplement/env override")
}
if _, ok := product.FindTool(invocation.Tool); !ok {
return r.fallback.Run(ctx, invocation)
// 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 {
invocation.DryRun = true
@@ -181,9 +237,200 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
if override, ok := productEndpointOverride(invocation.CanonicalProduct); ok {
endpoint = override
}
// Multi-server tool-name authority correction.
//
// When two envelope servers share the same cli.command (e.g. group-chat
// and im both publish `dws chat ...`), the endpoints[cmd] map in
// registerDynamicServer is the second-writer wins, and catalog FindProduct
// may pick the wrong product's Endpoint for a tool whose real owner is
// a different server. Cross-check the canonical tool→endpoint map: when
// the per-tool endpoint exists and differs from the per-product endpoint
// catalog returned, trust the tool-owner endpoint (the server that
// actually declares this tool in its toolOverrides).
if toolEndpoint, ok := directRuntimeToolEndpoint(invocation.Tool); ok && toolEndpoint != "" && toolEndpoint != endpoint {
endpoint = toolEndpoint
}
return r.executeInvocation(ctx, endpoint, invocation)
}
type multiProfileSelection struct {
Selector string
Profile authpkg.Profile
}
func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfileSelection, bool, error) {
rawSelector = strings.TrimSpace(rawSelector)
if rawSelector == "" || !strings.Contains(rawSelector, ",") {
return nil, false, nil
}
if p, err := authpkg.ResolveProfile(configDir, rawSelector); err == nil && p != nil {
return nil, false, nil
}
parts := strings.Split(rawSelector, ",")
selections := make([]multiProfileSelection, 0, len(parts))
seen := make(map[string]bool, len(parts))
for _, part := range parts {
selector := strings.TrimSpace(part)
if selector == "" {
return nil, false, fmt.Errorf("--profile contains an empty profile selector: %q", rawSelector)
}
profile, err := authpkg.ResolveProfile(configDir, selector)
if err != nil {
return nil, false, err
}
if profile == nil {
return nil, false, fmt.Errorf("profile %q not found", selector)
}
if seen[profile.CorpID] {
continue
}
seen[profile.CorpID] = true
selections = append(selections, multiProfileSelection{
Selector: selector,
Profile: *profile,
})
}
if len(selections) == 0 {
return nil, false, nil
}
return selections, true, nil
}
func (r *runtimeRunner) runMultiProfile(ctx context.Context, invocation executor.Invocation, selections []multiProfileSelection) (executor.Result, error) {
previousProfile := authpkg.RuntimeProfile()
defer authpkg.SetRuntimeProfile(previousProfile)
entries := make([]any, 0, len(selections))
succeeded := 0
failed := 0
for _, selection := range selections {
authpkg.SetRuntimeProfile(selection.Profile.CorpID)
result, err := r.runSingle(ctx, cloneInvocation(invocation), false)
entry := map[string]any{
"selector": selection.Selector,
"corpId": selection.Profile.CorpID,
"corpName": selection.Profile.CorpName,
"ok": err == nil,
}
if err != nil {
failed++
entry["error"] = multiProfileErrorPayload(err)
} else {
succeeded++
if payload := multiProfileResultPayload(result); payload != nil {
entry["result"] = payload
}
if result.Response != nil {
if endpoint, ok := result.Response["endpoint"]; ok {
entry["endpoint"] = endpoint
}
}
}
entries = append(entries, entry)
}
invocation.Implemented = true
return executor.Result{
Invocation: invocation,
Response: map[string]any{
"content": map[string]any{
"success": failed == 0,
"multiProfile": true,
"summary": map[string]any{
"total": len(selections),
"succeeded": succeeded,
"failed": failed,
},
"profiles": entries,
},
},
}, nil
}
func cloneInvocation(invocation executor.Invocation) executor.Invocation {
cloned := invocation
if invocation.Params != nil {
cloned.Params = make(map[string]any, len(invocation.Params))
for key, value := range invocation.Params {
cloned.Params[key] = value
}
}
return cloned
}
func multiProfileResultPayload(result executor.Result) any {
if result.Response == nil {
return nil
}
if content, ok := result.Response["content"]; ok {
return content
}
return result.Response
}
func multiProfileErrorPayload(err error) map[string]any {
payload := map[string]any{
"message": err.Error(),
}
var typed *apperrors.Error
if errors.As(err, &typed) {
payload["category"] = string(typed.Category)
if typed.Reason != "" {
payload["reason"] = typed.Reason
}
if typed.Operation != "" {
payload["operation"] = typed.Operation
}
if code := typed.ExitCode(); code != 0 {
payload["exitCode"] = code
}
}
return payload
}
// handleCatalogMiss decides what to do when discovery catalog does not cover the
// requested product / tool and no `directRuntimeEndpoint` match fired earlier.
//
// Previously every catalog miss silently fell through to EchoRunner, which
// returns an empty `executor.Result{Response: nil}`. The helper-invocation
// adapter then converted that into `&edition.ToolResult{}`, whose `Content`
// marshals to `null`, surfacing as `{"Content": null}` at the CLI. Users had no
// signal that endpoint resolution failed — see the fix-wukong-discovery-missing-servers plan (Phase 3) for the full trace.
//
// New contract:
// - Dry-run (invocation.DryRun or globalFlags.DryRun): keep EchoRunner so
// `--dry-run` still prints the planned payload without real execution.
// - Otherwise: return an explicit apperrors.NewAPI("endpoint_not_resolved")
// with the offending product/tool attached. This fails fast to stderr and
// makes missing envelopes / supplement gaps immediately visible.
func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation executor.Invocation, detail string) (executor.Result, error) {
dryRun := invocation.DryRun || (r.globalFlags != nil && r.globalFlags.DryRun)
if dryRun {
invocation.DryRun = true
return r.fallback.Run(ctx, invocation)
}
hint := "产品 envelope 可能未下发到 discovery,或已经被 serverDeps fail-fast 丢弃;可执行 'dws cache refresh' 强制重新 discovery,仍失败请向 Portal 确认 envelope 状态。"
actions := []string{"dws cache refresh"}
if strings.TrimSpace(invocation.CanonicalProduct) == devappProductID {
hint = "dev app(product id: devapp)是 helper-only 产品,命令树不依赖 discovery;真实调用需要内部版通过 SupplementServers/StaticServers 注入 MCP endpoint,或本地调试临时设置 DINGTALK_DEVAPP_MCP_URL。"
actions = []string{
"检查内部版 SupplementServers/StaticServers 是否包含 devapp endpoint",
"本地调试可临时设置 DINGTALK_DEVAPP_MCP_URL 后重试",
}
}
return executor.Result{}, apperrors.NewAPI(
fmt.Sprintf("endpoint not resolved for product %q (tool %q): %s", invocation.CanonicalProduct, invocation.Tool, detail),
apperrors.WithOperation("discovery.resolve"),
apperrors.WithReason("endpoint_not_resolved"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint(hint),
apperrors.WithActions(actions...),
)
}
func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (result executor.Result, retErr error) {
// Route stdio:// endpoints to the local StdioClient — no HTTP, no auth.
if IsStdioEndpoint(endpoint) {
@@ -239,6 +486,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
invocation.CanonicalProduct, invocation.Tool, endpoint, version, authToken != "", timeoutSec)
if invocation.DryRun {
// Emit a wukong-aligned human-readable preview on stderr so the dry-run
// surface advertises the resolved MCP arguments without polluting the
// stdout payload (which stays valid JSON in --format json mode). Mirrors
// wukong's "Arguments: {...}" dry-run line; stderr keeps it out of the
// machine-readable channel.
if argsJSON, err := json.Marshal(invocation.Params); err == nil {
fmt.Fprintf(os.Stderr, "DRY-RUN Arguments: %s\n", argsJSON)
}
return executor.Result{
Invocation: invocation,
Response: map[string]any{
@@ -295,6 +550,17 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
defer cancel()
}
if err := r.preflightDocDownload(callCtx, tc, endpoint, invocation); err != nil {
if patCheck := apperrors.AsPatAuthCheckError(err); patCheck != nil {
if IsPatRetrying(ctx) {
return executor.Result{}, patCheck
}
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
}
captureRuntimeFailure(invocation, err, err)
return executor.Result{}, err
}
callStart := time.Now()
callResult, err := tc.CallTool(callCtx, endpoint, invocation.Tool, invocation.Params)
RecordTiming(ctx, "mcp_call", time.Since(callStart))
@@ -387,6 +653,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}
invocation.Implemented = true
// Align with wukong's response envelope: stamp a top-level success=true on
// map payloads that don't already carry a success flag. Business errors
// (success=false) are intercepted above, so reaching here means the call
// succeeded. Additive only — existing keys are never overwritten.
if callResult.Content != nil {
if _, has := callResult.Content["success"]; !has {
callResult.Content["success"] = true
}
}
response := map[string]any{
"endpoint": transport.RedactURL(endpoint),
"content": callResult.Content,
@@ -481,28 +756,40 @@ func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string {
// Cached token state for process lifetime
var (
cachedRuntimeToken string
cachedRuntimeTokenOnce sync.Once
cachedRuntimeTokenMu sync.Mutex
cachedRuntimeTokens = map[string]string{}
)
// getCachedRuntimeToken returns a cached access token, loading it only once per process.
// This avoids repeated Keychain access which takes ~70ms each time.
func getCachedRuntimeToken(ctx context.Context) string {
cachedRuntimeTokenOnce.Do(func() {
loadStart := time.Now()
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
cacheKey := strings.TrimSpace(authpkg.RuntimeProfile())
if cacheKey == "" {
cacheKey = "__default__"
}
cachedRuntimeTokenMu.Lock()
if token := cachedRuntimeTokens[cacheKey]; token != "" {
cachedRuntimeTokenMu.Unlock()
return token
}
cachedRuntimeTokenMu.Unlock()
configDir := defaultConfigDir()
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
slog.Error(tokenErr.Error())
return
}
if token != "" {
cachedRuntimeToken = token
}
})
return cachedRuntimeToken
loadStart := time.Now()
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
configDir := defaultConfigDir()
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
slog.Error(tokenErr.Error())
return ""
}
if token == "" {
return ""
}
cachedRuntimeTokenMu.Lock()
cachedRuntimeTokens[cacheKey] = token
cachedRuntimeTokenMu.Unlock()
return token
}
// generateExecutionID returns a random 16-char hex string used to correlate
@@ -517,8 +804,9 @@ func generateExecutionID() string {
// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access.
// This should be called after login/logout operations.
func ResetRuntimeTokenCache() {
cachedRuntimeTokenOnce = sync.Once{}
cachedRuntimeToken = ""
cachedRuntimeTokenMu.Lock()
defer cachedRuntimeTokenMu.Unlock()
cachedRuntimeTokens = map[string]string{}
}
func newRuntimeContentScanner() safety.Scanner {
@@ -579,11 +867,46 @@ 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).
sessionID := os.Getenv(envDingtalkSessionID)
if sessionID == "" {
sessionID = os.Getenv(envDWSSessionID)
}
if sessionID == "" {
sessionID = os.Getenv(envRewindSessionID)
}
// Resolve the agent_code (accuracy-first; unknown hosts stay empty) and the
// per-(machine × agent_code) instance id when a code is known. Synthetic
// fallbacks must not be sent because PAT authorization checks use the same
// header as their grant key.
//
// Backward-compat by design (additive, not breaking):
// - x-dws-agent-id keeps its v1 meaning = machine-level install UUID
// (set by id.Headers() above), so old/new clients stay comparable.
// - x-dws-agent-instance-id is NEW: the per-(machine × agent_code) id,
// sent only when x-dingtalk-dws-agent-code is non-empty.
// Note: x-dws-channel (DWS_CHANNEL) is a separate axis, untouched.
agentCode, agentCodeSig := authpkg.DetectAgentCode()
if agentInstanceID := id.ResolveAgentID(defaultConfigDir(), agentCode, agentCodeSig); agentInstanceID != "" {
headers["x-dws-agent-instance-id"] = agentInstanceID
}
// Emit the CLI version on the wire so the gateway can segment old vs new
// clients (and scope agent_code coverage / adoption). The header constant
// existed but was never set; wire it here.
if version != "" {
headers[transport.HeaderVersion] = version
}
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": agentCode,
"x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID),
"x-dingtalk-session-id": sessionID,
"x-dingtalk-message-id": os.Getenv(envDingtalkMessageID),
}
for k, v := range envHeaders {
if v != "" {
@@ -591,7 +914,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
}
@@ -606,13 +930,23 @@ func resolveIdentityHeaders() map[string]string {
// errors (success=false + errorCode/errorMsg) that are not flagged at the MCP
// protocol level. Returns the error message, or "" if the response is OK.
func detectBusinessError(content map[string]any) string {
return detectBusinessErrorAtDepth(content, 0)
}
func detectBusinessErrorAtDepth(content map[string]any, depth int) string {
if content == nil || depth > 8 {
return ""
}
success, ok := content["success"]
if !ok {
return ""
return detectNestedBusinessError(content, depth)
}
b, ok := success.(bool)
if !ok || b {
return ""
return detectNestedBusinessError(content, depth)
}
if nested := detectNestedBusinessError(content, depth); nested != "" {
return nested
}
if msg, ok := content["errorMsg"].(string); ok && strings.TrimSpace(msg) != "" {
return strings.TrimSpace(msg)
@@ -623,6 +957,28 @@ func detectBusinessError(content map[string]any) string {
return "business error: success=false"
}
func detectNestedBusinessError(content map[string]any, depth int) string {
for _, key := range []string{"content", "result", "data"} {
switch child := content[key].(type) {
case map[string]any:
if msg := detectBusinessErrorAtDepth(child, depth+1); msg != "" {
return msg
}
case []any:
for _, item := range child {
childMap, ok := item.(map[string]any)
if !ok {
continue
}
if msg := detectBusinessErrorAtDepth(childMap, depth+1); msg != "" {
return msg
}
}
}
}
return ""
}
// extractMCPErrorMessage builds an error message from a ToolCallResult with
// isError=true. It extracts text from content blocks when available.
func extractMCPErrorMessage(result transport.ToolCallResult) string {
@@ -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)
}
}
+349 -26
View File
@@ -17,6 +17,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
@@ -25,7 +26,12 @@ import (
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
mockmcp "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/test/mock_mcp"
)
@@ -33,6 +39,13 @@ import (
func setupRuntimeCommandTest(t *testing.T) {
t.Helper()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
// Isolate keychain storage so concurrent test packages can't leak a
// real auth token into runtime tests via the shared on-disk keychain
// location. We deliberately do NOT reset the process-wide token
// cache here: getCachedRuntimeToken uses sync.Once and is read by
// detached preload goroutines spawned per Run(); replacing the Once
// races with those still-running goroutines.
t.Setenv(keychain.StorageDirEnv, t.TempDir())
discoverySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(contactDiscoveryResponse())
@@ -305,6 +318,279 @@ func TestRuntimeRunnerInjectsAuthTokenFromFlag(t *testing.T) {
}
}
func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv(authpkg.AgentCodeEnv, " QoderWork ")
t.Setenv(authpkg.AgentCodeEnvCompat, "")
headers := resolveIdentityHeaders()
if got := headers["x-dingtalk-dws-agent-code"]; got != "QoderWork" {
t.Fatalf("x-dingtalk-dws-agent-code = %q, want QoderWork", got)
}
}
func TestResolveIdentityHeadersAgentIdentityFields(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv(authpkg.AgentCodeEnv, "qoder")
headers := resolveIdentityHeaders()
// x-dws-agent-id stays machine-level (v1 install UUID): non-empty and NOT
// the dwsa_ instance form — this is the cross-version continuity anchor.
machineID := headers["x-dws-agent-id"]
if machineID == "" {
t.Fatal("x-dws-agent-id must stay populated (machine-level)")
}
if strings.HasPrefix(machineID, "dwsa_") {
t.Fatalf("x-dws-agent-id must remain machine-level, got instance form %q", machineID)
}
// x-dws-agent-instance-id is the NEW per-(machine × agent_code) id.
instID := headers["x-dws-agent-instance-id"]
if !strings.HasPrefix(instID, "dwsa_") {
t.Fatalf("x-dws-agent-instance-id must be a derived instance id, got %q", instID)
}
if instID == machineID {
t.Fatal("instance id must differ from machine id")
}
// CLI version must now be on the wire so the gateway can segment old/new.
if headers[transport.HeaderVersion] == "" {
t.Fatalf("%s must be emitted", transport.HeaderVersion)
}
}
func TestResolveIdentityHeadersIgnoresReversedAgentCodeEnv(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv(authpkg.AgentCodeEnv, "")
t.Setenv("DWS_DINGTALK_AGENTCODE", " compat ")
// Isolate from ambient agent-host detection signals so this test asserts
// only the reversed-env-name behavior (the suite itself may run under
// Claude Code / Qoder / VS Code, whose signals would otherwise be detected).
for _, k := range []string{
"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT",
"OPENCLAW_BUNDLE_ROOT", "OPENCLAW_RUNTIME_ROLE", "HERMES_HOME",
"CODEX_SANDBOX", "VSCODE_BRAND", "__CFBundleIdentifier",
} {
t.Setenv(k, "")
}
headers := resolveIdentityHeaders()
// The reversed env name must never be consumed. With no canonical
// declaration and no host signature, agent_code stays empty rather than
// falling back to a synthetic key.
if got, ok := headers["x-dingtalk-dws-agent-code"]; ok {
t.Fatalf("x-dingtalk-dws-agent-code = %q, want header omitted", got)
}
if got, ok := headers["x-dws-agent-instance-id"]; ok {
t.Fatalf("x-dws-agent-instance-id = %q, want header omitted when agent_code is empty", got)
}
}
func TestResolveIdentityHeadersSessionEnvPriority(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv(envDingtalkSessionID, "ding-session")
t.Setenv(envDWSSessionID, "dws-session")
t.Setenv(envRewindSessionID, "rewind-session")
headers := resolveIdentityHeaders()
if got := headers["x-dingtalk-session-id"]; got != "ding-session" {
t.Fatalf("x-dingtalk-session-id = %q, want DINGTALK_SESSION_ID", got)
}
t.Setenv(envDingtalkSessionID, "")
headers = resolveIdentityHeaders()
if got := headers["x-dingtalk-session-id"]; got != "dws-session" {
t.Fatalf("x-dingtalk-session-id = %q, want DWS_SESSION_ID", got)
}
t.Setenv(envDWSSessionID, "")
headers = resolveIdentityHeaders()
if got := headers["x-dingtalk-session-id"]; got != "rewind-session" {
t.Fatalf("x-dingtalk-session-id = %q, want REWIND_SESSION_ID", got)
}
}
func TestDocDownloadPreflightRejectsAXLSBeforeDownloadPAT(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
var calls []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := jsonRPCToolName(req)
calls = append(calls, name)
switch name {
case docGetDocumentInfoTool:
writeJSONRPCToolResult(t, w, req, map[string]any{
"success": true,
"result": map[string]any{
"contentType": "ALIDOC",
"extension": "axls",
"nodeType": "file",
},
}, false)
case docDownloadFileTool:
t.Fatalf("download_file should not be called for axls")
default:
http.Error(w, "unexpected tool "+name, http.StatusBadRequest)
}
}))
defer server.Close()
runner := runtimeRunnerForHTTPTest(server)
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: docProductID,
Tool: docDownloadFileTool,
CanonicalPath: "doc.download_file",
Params: map[string]any{"nodeId": "axls-node"},
})
if err == nil {
t.Fatal("executeInvocation() error = nil, want axls rejection")
}
if !strings.Contains(err.Error(), "extension=axls") {
t.Fatalf("executeInvocation() error = %v, want extension=axls guidance", err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("executeInvocation() error = %T, want *errors.Error", err)
}
if typed.Category != apperrors.CategoryValidation {
t.Fatalf("error category = %q, want validation", typed.Category)
}
if typed.Reason != "unsupported_alidoc_extension" {
t.Fatalf("error reason = %q, want unsupported_alidoc_extension", typed.Reason)
}
if got := strings.Join(calls, ","); got != docGetDocumentInfoTool {
t.Fatalf("tool calls = %q, want only %s", got, docGetDocumentInfoTool)
}
}
func TestDocDownloadPreflightAllowsNonAXLSDownload(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
var calls []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := jsonRPCToolName(req)
calls = append(calls, name)
switch name {
case docGetDocumentInfoTool:
writeJSONRPCToolResult(t, w, req, map[string]any{
"success": true,
"result": map[string]any{
"contentType": "DRIVE",
"extension": "xlsx",
"nodeType": "file",
},
}, false)
case docDownloadFileTool:
writeJSONRPCToolResult(t, w, req, map[string]any{
"resourceUrl": []any{"https://example.invalid/file.xlsx"},
}, false)
default:
http.Error(w, "unexpected tool "+name, http.StatusBadRequest)
}
}))
defer server.Close()
runner := runtimeRunnerForHTTPTest(server)
result, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: docProductID,
Tool: docDownloadFileTool,
CanonicalPath: "doc.download_file",
Params: map[string]any{"nodeId": "xlsx-node"},
})
if err != nil {
t.Fatalf("executeInvocation() error = %v", err)
}
if got := strings.Join(calls, ","); got != docGetDocumentInfoTool+","+docDownloadFileTool {
t.Fatalf("tool calls = %q, want preflight then download", got)
}
content, ok := result.Response["content"].(map[string]any)
if !ok {
t.Fatalf("response.content = %#v, want map", result.Response["content"])
}
if _, ok := content["resourceUrl"]; !ok {
t.Fatalf("response.content.resourceUrl missing: %#v", content)
}
}
func TestDocDownloadPreflightPATAuthorizationUsesExistingHandler(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
originalOpenBrowser := openBrowserFunc
var openedURI string
openBrowserFunc = func(uri string) error {
openedURI = uri
return nil
}
t.Cleanup(func() { openBrowserFunc = originalOpenBrowser })
const authURI = "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-1%26userCode%3DCODE#/personalAuthorization?flowId=flow-1&userCode=CODE"
var calls []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := jsonRPCToolName(req)
calls = append(calls, name)
switch name {
case docGetDocumentInfoTool:
writeJSONRPCToolResult(t, w, req, map[string]any{
"code": "PAT_MEDIUM_RISK_NO_PERMISSION",
"data": map[string]any{
"flowId": "flow-1",
"uri": authURI,
"clientId": "client-1",
},
}, false)
case docDownloadFileTool:
t.Fatalf("download_file should not be called before preflight PAT authorization")
default:
http.Error(w, "unexpected tool "+name, http.StatusBadRequest)
}
}))
defer server.Close()
runner := runtimeRunnerForHTTPTest(server)
runner.globalFlags.Format = "json"
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: docProductID,
Tool: docDownloadFileTool,
CanonicalPath: "doc.download_file",
Params: map[string]any{"nodeId": "pat-node"},
})
if err == nil {
t.Fatal("executeInvocation() error = nil, want PAT error")
}
var patErr *apperrors.PATError
if !errors.As(err, &patErr) {
t.Fatalf("executeInvocation() error = %T, want *errors.PATError", err)
}
if openedURI != authURI {
t.Fatalf("opened URI = %q, want %q", openedURI, authURI)
}
if got := strings.Join(calls, ","); got != docGetDocumentInfoTool {
t.Fatalf("tool calls = %q, want only %s before PAT authorization", got, docGetDocumentInfoTool)
}
}
// TestRuntimeRunnerRejectsUnauthenticatedRequest verifies that requests without
// a valid token are rejected with a clear error before making any network call.
func TestRuntimeRunnerRejectsUnauthenticatedRequest(t *testing.T) {
@@ -336,7 +622,17 @@ func TestRuntimeRunnerRejectsUnauthenticatedRequest(t *testing.T) {
}
}
func TestRuntimeRunnerFallsBackForUnavailableProduct(t *testing.T) {
// TestRuntimeRunnerErrorsForUnavailableProduct pins down the post-fix
// (fix-wukong-discovery-missing-servers Phase 3) behaviour: when the catalog
// does not carry the requested product (here `contact` against a
// doc-only fixture), `runtimeRunner.Run` must surface an explicit
// `endpoint_not_resolved` error instead of silently echoing an empty result
// back to the user — the latter is what historically produced `{"Content":
// null}` output on `dws doc list`.
//
// Dry-run / compat semantics continue to route through EchoRunner and are
// covered by other tests in this file (TestRuntime*Dry*, TestCompat*).
func TestRuntimeRunnerErrorsForUnavailableProduct(t *testing.T) {
setupRuntimeCommandTest(t)
server := mockmcp.DefaultServer()
defer server.Close()
@@ -349,33 +645,16 @@ func TestRuntimeRunnerFallsBackForUnavailableProduct(t *testing.T) {
cmd.SetErr(&out)
cmd.SetArgs([]string{"-f", "json", "contact", "user", "get-self"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
err := cmd.Execute()
if err == nil {
t.Fatalf("Execute() error = nil, want endpoint_not_resolved error\noutput:\n%s", out.String())
}
var payload struct {
Invocation struct {
Implemented bool `json:"implemented"`
CanonicalProduct string `json:"canonical_product"`
Tool string `json:"tool"`
} `json:"invocation"`
Response map[string]any `json:"response"`
msg := err.Error()
if !strings.Contains(msg, "endpoint not resolved") {
t.Fatalf("error message = %q, want it to contain \"endpoint not resolved\"", msg)
}
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v\noutput:\n%s", err, out.String())
}
if payload.Invocation.Implemented {
t.Fatalf("implemented = true, want false for fallback")
}
if payload.Invocation.CanonicalProduct != "contact" {
t.Fatalf("canonical_product = %q, want contact", payload.Invocation.CanonicalProduct)
}
if payload.Invocation.Tool != "get_current_user_profile" {
t.Fatalf("tool = %q, want get_current_user_profile", payload.Invocation.Tool)
}
if payload.Response != nil {
t.Fatalf("response = %#v, want nil for echo fallback", payload.Response)
if !strings.Contains(msg, "contact") {
t.Fatalf("error message = %q, want it to mention product \"contact\"", msg)
}
}
@@ -646,6 +925,50 @@ func contentScanServer() *mockmcp.Server {
return mockmcp.MustNewServer(fixture)
}
func runtimeRunnerForHTTPTest(server *httptest.Server) *runtimeRunner {
client := transport.NewClient(server.Client())
client.Stderr = &bytes.Buffer{}
return &runtimeRunner{
transport: client,
globalFlags: &GlobalFlags{Token: "test-token", Timeout: 30},
}
}
func jsonRPCToolName(req map[string]any) string {
params, _ := req["params"].(map[string]any)
if params == nil {
return ""
}
name, _ := params["name"].(string)
return name
}
func TestDetectBusinessErrorNestedServiceResult(t *testing.T) {
content := map[string]any{
"success": false,
"result": map[string]any{
"success": false,
"errorCode": "ROBOT_NOT_FOUND",
"errorMsg": "robot info is not exist",
},
}
if got := detectBusinessError(content); got != "robot info is not exist" {
t.Fatalf("detectBusinessError() = %q, want nested errorMsg", got)
}
}
func writeJSONRPCToolResult(t *testing.T, w http.ResponseWriter, req map[string]any, content map[string]any, isError bool) {
t.Helper()
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": req["id"],
"result": map[string]any{
"content": content,
"isError": isError,
},
})
}
func TestClassifyToolResultHookPreemptsBusinessError(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
+71 -14
View File
@@ -24,6 +24,7 @@ import (
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
@@ -85,24 +86,81 @@ type CliSkillDTO struct {
// agentSkillPaths maps target names to their relative skill installation paths.
// These paths are relative to the user's home directory.
//
// Source of truth for both `dws skill install <skillId> <target>` and
// `dws skill setup --target <name>`. Every entry in skillSetupAgentHomes
// (skill_setup.go) MUST have a matching path value here — enforced by
// TestAgentSkillPathsCoversSetupHomes.
var agentSkillPaths = map[string]string{
"qoder": ".qoder/skills",
"claude": ".claude/skills",
"cursor": ".cursor/skills",
"codex": ".codex/skills",
"opencode": filepath.Join(".config", "opencode", "skills"),
// `agents` is the generic-agent sentinel: install scripts and `setup`
// special-case ~/.agents/skills as a no-checks-required fallback so a
// fresh machine without any IDE/agent registry still gets skills.
"agents": ".agents/skills",
"qoder": ".qoder/skills",
"qoderwork": ".qoderwork/skills",
"claude": ".claude/skills",
"cursor": ".cursor/skills",
"codex": ".codex/skills",
"opencode": filepath.Join(".config", "opencode", "skills"),
// IDE / agent registries also probed by `dws skill setup --target all`.
"gemini": ".gemini/skills",
"github": ".github/skills",
"windsurf": ".windsurf/skills",
"augment": ".augment/skills",
"cline": ".cline/skills",
"amp": ".amp/skills",
"kiro": ".kiro/skills",
"trae": ".trae/skills",
"openclaw": ".openclaw/skills",
"hermes": ".hermes/skills",
}
// supportedTargets returns a comma-separated list of supported targets.
// supportedTargets returns a sorted, comma-separated list of supported
// targets. Sorted so help text and error messages stay stable across runs
// (Go map iteration order is intentionally randomized).
func supportedTargets() string {
targets := make([]string, 0, len(agentSkillPaths)+1)
for target := range agentSkillPaths {
targets = append(targets, target)
}
sort.Strings(targets)
targets = append(targets, ".")
return strings.Join(targets, ", ")
}
// longestAgentTargetName returns the character count of the longest target
// name in agentSkillPaths. Used by --help formatting to keep the "." entry
// vertically aligned with named targets.
func longestAgentTargetName() int {
n := 0
for name := range agentSkillPaths {
if len(name) > n {
n = len(name)
}
}
return n
}
// formatAgentSkillPathsForHelp renders agentSkillPaths as an aligned
// " <name> -> ~/<path>" block, sorted by name, for use in --help output.
// Keeps `dws skill install --help` in sync with the map without hand-edits.
func formatAgentSkillPathsForHelp() string {
names := make([]string, 0, len(agentSkillPaths))
maxWidth := 0
for n := range agentSkillPaths {
names = append(names, n)
if len(n) > maxWidth {
maxWidth = len(n)
}
}
sort.Strings(names)
var b strings.Builder
for _, n := range names {
fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, agentSkillPaths[n])
}
return b.String()
}
func buildSkillCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "skill",
@@ -122,6 +180,7 @@ func buildSkillCommand() *cobra.Command {
newSkillSearchCommand(),
newSkillFindHintCommand(),
newSkillAddHintCommand(),
newSkillSetupCommand(),
)
return cmd
}
@@ -179,17 +238,15 @@ func newSkillInstallCommand() *cobra.Command {
target 安装目标(必填),支持: %s
安装路径:
qoder -> ~/.qoder/skills/
claude -> ~/.claude/skills/
cursor -> ~/.cursor/skills/
codex -> ~/.codex/skills/
opencode -> ~/.config/opencode/skills/
. -> 当前目录
%s .%s -> 当前目录
示例:
dws skill install skill-123 qoder # 安装到 ~/.qoder/skills/
dws skill install skill-123 claude # 安装到 ~/.claude/skills/
dws skill install skill-123 . # 安装到当前目录`, supportedTargets()),
dws skill install skill-123 qoder # 安装到 ~/.qoder/skills/
dws skill install skill-123 . # 安装到当前目录`,
supportedTargets(),
formatAgentSkillPathsForHelp(),
strings.Repeat(" ", longestAgentTargetName()-1)),
Args: cobra.ExactArgs(2),
DisableAutoGenTag: true,
RunE: runSkillAdd,
+29 -2
View File
@@ -452,8 +452,14 @@ func TestFetchSkillDownloadInfoUnauthorized(t *testing.T) {
func TestSupportedTargets(t *testing.T) {
targets := supportedTargets()
// Should contain all predefined targets
expectedTargets := []string{"qoder", "claude", "cursor", "codex", "opencode", "."}
// Should contain all predefined targets — including the agents/* sentinel
// and the IDE/agent registries we share with skillSetupAgentHomes.
expectedTargets := []string{
"agents", "claude", "cursor", "codex", "opencode", "qoder",
"gemini", "github", "windsurf", "augment", "cline",
"amp", "kiro", "trae", "openclaw", "hermes",
".",
}
for _, expected := range expectedTargets {
if !strings.Contains(targets, expected) {
t.Errorf("supportedTargets() should contain %s, got: %s", expected, targets)
@@ -461,6 +467,27 @@ func TestSupportedTargets(t *testing.T) {
}
}
// TestAgentSkillPathsCoversSetupHomes guards against drift between
// agentSkillPaths (used by `dws skill install` and `dws skill setup
// --target <name>`) and skillSetupAgentHomes (used by `dws skill setup
// --target all` to detect candidate agent homes).
//
// Every path in skillSetupAgentHomes MUST be reachable via at least one
// entry in agentSkillPaths — otherwise `--target all` would silently
// install into agent homes that the user cannot address by name.
func TestAgentSkillPathsCoversSetupHomes(t *testing.T) {
paths := make(map[string]bool, len(agentSkillPaths))
for _, p := range agentSkillPaths {
paths[p] = true
}
for _, home := range skillSetupAgentHomes {
if !paths[home] {
t.Errorf("skillSetupAgentHomes entry %q has no matching agentSkillPaths value — "+
"add it to agentSkillPaths so users can address it via --target <name>", home)
}
}
}
func TestAgentSkillPathsCrossPlatform(t *testing.T) {
// Verify that paths use platform-appropriate separators
for target, path := range agentSkillPaths {
+711
View File
@@ -0,0 +1,711 @@
package app
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
)
// skillSetupAgentHomes is the ordered list of agent home subdirectories
// where dws skills get installed. Mirrors install.sh / install.ps1 /
// build/npm/install.js so that `dws skill setup` and the install scripts
// agree on the install footprint.
var skillSetupAgentHomes = []string{
".agents/skills",
".claude/skills",
".cursor/skills",
".qoder/skills",
".qoderwork/skills",
".gemini/skills",
".codex/skills",
".github/skills",
".windsurf/skills",
".augment/skills",
".cline/skills",
".amp/skills",
".kiro/skills",
".trae/skills",
".openclaw/skills",
".hermes/skills",
}
const (
skillSetupModeMono = "mono"
skillSetupModeMulti = "multi"
)
func newSkillSetupCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "setup",
Short: "安装 dws 自身 skill 到 Agent 目录",
Long: `安装 dws 自身 skill 文档到 AI Agent 目录(如 ~/.claude/skills/、~/.cursor/skills/ 等)。
支持两种模式:
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
multi 🧪 EXPERIMENTAL 多 skill(试验版 / Preview)—— 按产品拆 N 个独立 skill
尚未达到 stable 标准,接口、命名与跨 skill 引用可能变动;
生产前请评估,问题请提 issue 反馈
multi 模式支持按产品挑选:
-s/--skill 只装指定子 skill(可重复,短名 aitable 或全名 dingtalk-aitable 均可)
-x/--exclude 从全装里剔除指定子 skill(可重复,与 --skill 互斥)
未列出的已有 dingtalk-* skill 会保留(additive 叠加语义)
不带 --mode 时进入交互式询问;不带 --target 时铺到所有检测到的 Agent 目录。
skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`,
Example: ` dws skill setup # 交互式
dws skill setup --mode mono --yes # 非交互装 mono
dws skill setup --mode multi --target claude # multi 全装到 ~/.claude/skills/
dws skill setup --mode multi -s aitable -s calendar # 只装 aitable + calendar
dws skill setup --mode multi -x live -x devdoc # 装其余 18 个,剔除 2 个
dws skill setup --source /path/to/repo # 显式指定 skill 源`,
DisableAutoGenTag: true,
RunE: runSkillSetup,
}
cmd.Flags().String("mode", "", "skill 模式:mono | multi(不指定则交互询问)")
cmd.Flags().String("target", "all", "目标 Agent:all | "+supportedTargets())
cmd.Flags().String("source", "", "skill 源目录(默认使用二进制内嵌的 skill 源,与当前版本一致)")
cmd.Flags().Bool("yes", false, "跳过所有确认提示")
cmd.Flags().StringSliceP("skill", "s", nil, "multi 模式:仅安装指定子 skill(可重复,接受短名 aitable 或全名 dingtalk-aitable)")
cmd.Flags().StringSliceP("exclude", "x", nil, "multi 模式:从全装中剔除指定子 skill(可重复,与 --skill 互斥)")
return cmd
}
func runSkillSetup(cmd *cobra.Command, _ []string) error {
mode, _ := cmd.Flags().GetString("mode")
target, _ := cmd.Flags().GetString("target")
source, _ := cmd.Flags().GetString("source")
autoYes, _ := cmd.Flags().GetBool("yes")
includeRaw, _ := cmd.Flags().GetStringSlice("skill")
excludeRaw, _ := cmd.Flags().GetStringSlice("exclude")
out := cmd.OutOrStdout()
errOut := cmd.ErrOrStderr()
mode, err := resolveSkillSetupMode(mode, autoYes, out)
if err != nil {
return err
}
if mode == skillSetupModeMono && (len(includeRaw) > 0 || len(excludeRaw) > 0) {
return fmt.Errorf("--skill / --exclude 仅在 --mode multi 下有效(mono 只有一个 skill,无需挑选)")
}
skillSrc, srcCleanup, err := resolveSkillSetupSourceOrEmbedded(source, mode)
if err != nil {
return err
}
defer srcCleanup()
dests, err := resolveSkillSetupTargets(target, mode)
if err != nil {
return err
}
// multi 模式枚举 src 下的子 skill 名,供确认信息与安装步骤共用
var multiSkillNames []string
if mode == skillSetupModeMulti {
allMultiSkillNames, listErr := listMultiSkillNames(skillSrc)
if listErr != nil {
return listErr
}
if len(allMultiSkillNames) == 0 {
return fmt.Errorf("multi 模式下 %s 内未发现含 SKILL.md 的子目录", skillSrc)
}
filtered, filterErr := filterMultiSkillNames(allMultiSkillNames, includeRaw, excludeRaw)
if filterErr != nil {
return filterErr
}
// dws-shared carries the global rules every product skill declares as a
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
}
if !autoYes {
ok, err := confirmSkillSetup(out, mode, skillSrc, dests, multiSkillNames)
if err != nil {
return err
}
if !ok {
fmt.Fprintln(out, "已取消。")
return nil
}
} else if mode == skillSetupModeMulti {
fmt.Fprintln(errOut, "🧪 multi 模式当前为 EXPERIMENTAL(试验版 / Preview)—— 接口与布局可能变动,稳定版请用 --mode mono")
}
var installed, skipped int
switch mode {
case skillSetupModeMono:
installed, skipped, err = installSkillToHomes(skillSrc, dests, out, errOut)
case skillSetupModeMulti:
installed, skipped, err = installMultiSkillToHomes(skillSrc, multiSkillNames, dests, out, errOut)
default:
return fmt.Errorf("内部错误:未知 mode %q", mode)
}
if err != nil {
return err
}
fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped)
return nil
}
// multiSkillPrefix is the canonical prefix for every per-product skill
// bundle in skills/multi/ (e.g. dingtalk-aitable, dingtalk-calendar).
const multiSkillPrefix = "dingtalk-"
// multiSharedSkill is the shared, non-product skill that every per-product
// skill declares as a PREREQUISITE. It must always be installed in multi mode
// regardless of --skill / --exclude, otherwise the product skills reference a
// dws-shared that was never installed.
const multiSharedSkill = "dws-shared"
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
func ensureMandatorySharedSkill(selected, all []string) []string {
hasShared := false
for _, n := range all {
if n == multiSharedSkill {
hasShared = true
break
}
}
if !hasShared {
return selected
}
for _, n := range selected {
if n == multiSharedSkill {
return selected
}
}
return append([]string{multiSharedSkill}, selected...)
}
// normalizeMultiSkillName accepts either the short form (aitable) or the
// full form (dingtalk-aitable) and returns the canonical full form.
// Empty input returns "". Comparison is case-insensitive.
func normalizeMultiSkillName(name string) string {
n := strings.ToLower(strings.TrimSpace(name))
if n == "" {
return ""
}
if strings.HasPrefix(n, multiSkillPrefix) {
return n
}
return multiSkillPrefix + n
}
// filterMultiSkillNames narrows `all` by include / exclude lists.
// Semantics mirror lark-cli's `npx skills add -s lark-calendar`:
//
// - include + exclude are mutually exclusive (both → error)
// - names accept short or full form; normalized before matching
// - unknown names → error, with the available list inlined for discovery
// - both lists empty → return `all` (install everything)
// - exclude that drops every name → error (avoid silent no-op install)
//
// The caller is responsible for additive installation: install only the
// returned names, leaving any other already-installed dingtalk-* siblings
// untouched (handled by installMultiSkillToHomes which does not enumerate
// the destination).
func filterMultiSkillNames(all, include, exclude []string) ([]string, error) {
if len(include) > 0 && len(exclude) > 0 {
return nil, fmt.Errorf("--skill 与 --exclude 不能同时使用")
}
available := make(map[string]struct{}, len(all))
for _, n := range all {
available[n] = struct{}{}
}
validate := func(raw []string, flagName string) ([]string, error) {
var normalized []string
var unknown []string
seen := make(map[string]bool)
for _, r := range raw {
n := normalizeMultiSkillName(r)
if n == "" {
continue
}
if _, ok := available[n]; !ok {
unknown = append(unknown, r)
continue
}
if !seen[n] {
seen[n] = true
normalized = append(normalized, n)
}
}
if len(unknown) > 0 {
return nil, fmt.Errorf("%s 中的以下名称在 multi 源中找不到:%s\n可用列表(共 %d 个):%s",
flagName, strings.Join(unknown, ", "), len(all), strings.Join(all, ", "))
}
return normalized, nil
}
if len(include) > 0 {
names, err := validate(include, "--skill")
if err != nil {
return nil, err
}
sort.Strings(names)
return names, nil
}
if len(exclude) > 0 {
excluded, err := validate(exclude, "--exclude")
if err != nil {
return nil, err
}
excludedSet := make(map[string]bool, len(excluded))
for _, n := range excluded {
excludedSet[n] = true
}
var out []string
for _, n := range all {
if !excludedSet[n] {
out = append(out, n)
}
}
if len(out) == 0 {
return nil, fmt.Errorf("--exclude 把全部 %d 个子 skill 都剔除了,没有可装的", len(all))
}
return out, nil
}
return all, nil
}
// listMultiSkillNames returns sorted names of subdirectories under src that
// contain a SKILL.md file (i.e. valid multi-mode skill bundles).
func listMultiSkillNames(src string) ([]string, error) {
entries, err := os.ReadDir(src)
if err != nil {
return nil, fmt.Errorf("无法读取 multi skill 源目录 %s: %w", src, err)
}
var names []string
for _, e := range entries {
if !e.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(src, e.Name(), "SKILL.md")); err == nil {
names = append(names, e.Name())
}
}
sort.Strings(names)
return names, nil
}
// resolveSkillSetupMode resolves the mode either from the flag or via an
// interactive prompt. If no TTY is available and no mode was given, returns
// an error rather than silently picking a default.
func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, error) {
mode = strings.ToLower(strings.TrimSpace(mode))
switch mode {
case skillSetupModeMono, skillSetupModeMulti:
return mode, nil
case "":
// fall through to interactive prompt
default:
return "", fmt.Errorf("不支持的 --mode 值: %s(可选 mono / multi)", mode)
}
if autoYes || !isInteractiveTerminal() {
fmt.Fprintln(out, "未指定 --mode,非交互环境下默认使用 mono")
return skillSetupModeMono, nil
}
var choice string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("选择 dws skill 安装模式").
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分(🧪 EXPERIMENTAL / 试验版,未达 stable,接口可能变动)").
Options(
huh.NewOption("mono — 单 skill(稳定 / 推荐)", skillSetupModeMono),
huh.NewOption("multi — 多 skill(🧪 EXPERIMENTAL · 试验版)", skillSetupModeMulti),
).
Value(&choice),
),
)
if err := form.Run(); err != nil {
return "", fmt.Errorf("交互式选择中止: %w", err)
}
return choice, nil
}
// resolveSkillSetupSource finds the local skill source directory for the
// given mode. PR 1 supports only mono; multi is reserved for a later PR
// and currently returns an error before reaching this function.
func resolveSkillSetupSource(explicit, mode string) (string, error) {
subdir := mode // "mono" or "multi"
// An explicit override (--source flag or DWS_SKILL_SOURCE) wins, and an
// override that does not contain a skill root is an error — never a
// silent fallback to another source the user did not ask for.
var overrides []string
if explicit != "" {
overrides = append(overrides, explicit, filepath.Join(explicit, "skills", subdir))
}
if env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE")); env != "" {
overrides = append(overrides, env, filepath.Join(env, "skills", subdir))
}
if len(overrides) > 0 {
for _, c := range overrides {
if isSkillSourceRoot(c, mode) {
return c, nil
}
}
hint := strings.Join(overrides, "\n - ")
return "", fmt.Errorf("未找到 %s 模式的 skill 源目录(--source / DWS_SKILL_SOURCE 显式指定时不回退到内嵌源),已尝试:\n - %s", mode, hint)
}
// No explicit override: legacy fallback only — embedded materialization
// is handled by resolveSkillSetupSourceOrEmbedded (skill_setup_embed.go),
// the wrapper that callers use. This branch is reachable only when the
// wrapper passes through with an empty explicit/env (legacy direct call).
candidates := skillSourceCandidates("", subdir)
for _, c := range candidates {
if isSkillSourceRoot(c, mode) {
return c, nil
}
}
hint := strings.Join(candidates, "\n - ")
return "", fmt.Errorf("未找到 %s 模式的 skill 源目录,已尝试:\n - %s\n\n请用 --source 显式指定包含 skills/%s 的仓库根目录", mode, hint, mode)
}
// skillSourceCandidates returns the ordered list of paths to probe for a
// skill source root, given an optional explicit override and the mode
// subdir (mono or multi).
func skillSourceCandidates(explicit, subdir string) []string {
var roots []string
if explicit != "" {
// allow either repo root or already-resolved skills/<mode> dir
roots = append(roots, explicit, filepath.Join(explicit, "skills", subdir))
}
if env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE")); env != "" {
roots = append(roots, env, filepath.Join(env, "skills", subdir))
}
if exe, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exe)
roots = append(roots,
filepath.Join(exeDir, "skills", subdir),
filepath.Join(exeDir, "..", "skills", subdir),
filepath.Join(exeDir, "..", "share", "skills", "dws"),
)
}
if wd, err := os.Getwd(); err == nil {
roots = append(roots, filepath.Join(wd, "skills", subdir))
}
// User-level cache populated by install.sh / install.ps1 / npm install.js
// from the dws-skills.zip release asset. Lets `dws skill setup` find a
// source even when the user has no source checkout on disk.
if home, err := os.UserHomeDir(); err == nil {
roots = append(roots, filepath.Join(home, ".dws", "skills", subdir))
}
return roots
}
func isSkillSourceRoot(path, mode string) bool {
if path == "" {
return false
}
switch mode {
case skillSetupModeMono:
fi, err := os.Stat(filepath.Join(path, "SKILL.md"))
return err == nil && !fi.IsDir()
case skillSetupModeMulti:
entries, err := os.ReadDir(path)
if err != nil {
return false
}
for _, e := range entries {
if e.IsDir() {
if _, err := os.Stat(filepath.Join(path, e.Name(), "SKILL.md")); err == nil {
return true
}
}
}
return false
}
return false
}
// resolveSkillSetupTargets returns the list of absolute Agent home destinations.
// If target == "all", returns every agent home whose parent directory exists.
// Otherwise returns the single matching home (whether or not it currently exists).
//
// 末段约定:
// - mono → <agent-home>/dws (单 skill,整个 src 拷成一个 dws 目录)
// - multi → <agent-home> (安装时把 src 下每个子目录拷成兄弟 skill)
func resolveSkillSetupTargets(target, mode string) ([]string, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("无法解析用户 HOME: %w", err)
}
target = strings.ToLower(strings.TrimSpace(target))
if target == "" || target == "all" {
return detectExistingAgentHomes(home, mode), nil
}
rel, ok := agentSkillPaths[target]
if !ok {
return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets())
}
return []string{agentHomeForMode(filepath.Join(home, rel), mode)}, nil
}
// agentHomeForMode appends the mode-specific tail segment to an agent home base.
func agentHomeForMode(base, mode string) string {
if mode == skillSetupModeMulti {
return base
}
return filepath.Join(base, "dws")
}
func detectExistingAgentHomes(home, mode string) []string {
var out []string
for i, rel := range skillSetupAgentHomes {
base := filepath.Join(home, rel)
parent := filepath.Dir(base)
if i > 0 {
if _, err := os.Stat(parent); errors.Is(err, os.ErrNotExist) {
continue
}
}
out = append(out, agentHomeForMode(base, mode))
}
if len(out) == 0 {
out = append(out, agentHomeForMode(filepath.Join(home, ".agents", "skills"), mode))
}
return out
}
func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string) (bool, error) {
if mode == skillSetupModeMulti {
fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
fmt.Fprintln(out, " · 20 个 dingtalk-* 子 skill 跑过 verifier,可用但未达 stable")
fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
fmt.Fprintln(out, " 稳定版请用 --mode mono")
fmt.Fprintln(out, "🧪 ─────────────────────────────────────────────────────────────")
}
fmt.Fprintf(out, "\n📦 将安装 skill:\n mode: %s\n source: %s\n", mode, src)
if mode == skillSetupModeMulti {
fmt.Fprintf(out, " 将装 %d 个独立 skill(按子目录平铺到 <agent-home>/<skill-name>/):\n", len(multiSkillNames))
for _, n := range multiSkillNames {
fmt.Fprintf(out, " · %s\n", n)
}
}
fmt.Fprintln(out, " destinations:")
for _, d := range dests {
fmt.Fprintf(out, " - %s\n", d)
}
// 列出互斥清理:装 mode 前要把对面 mode 的残留删掉
fmt.Fprintln(out, " 互斥清理(确认后才执行):")
for _, d := range dests {
for _, victim := range mutualExclusionVictims(d, mode) {
fmt.Fprintf(out, " × 将删除 %s\n", victim)
}
}
if !isInteractiveTerminal() {
return true, nil
}
var confirm bool
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("确认安装?").
Affirmative("继续").
Negative("取消").
Value(&confirm),
),
)
if err := form.Run(); err != nil {
return false, fmt.Errorf("确认中止: %w", err)
}
return confirm, nil
}
// mutualExclusionVictims returns the paths that should be removed before
// installing into dest under the given mode, to prevent leftover files from
// the opposite mode from co-existing.
//
// - mono dest is <agent-home>/dws → multi 残留是 <agent-home>/dingtalk-*
// - multi dest is <agent-home> → mono 残留是 <agent-home>/dws
func mutualExclusionVictims(dest, mode string) []string {
switch mode {
case skillSetupModeMono:
// dest = <agent-home>/dws → agent-home = parent
agentHome := filepath.Dir(dest)
entries, err := os.ReadDir(agentHome)
if err != nil {
return nil
}
var victims []string
for _, e := range entries {
if e.IsDir() && strings.HasPrefix(e.Name(), "dingtalk-") {
victims = append(victims, filepath.Join(agentHome, e.Name()))
}
}
sort.Strings(victims)
return victims
case skillSetupModeMulti:
// dest = <agent-home> → mono 残留是 dest/dws
monoPath := filepath.Join(dest, "dws")
if _, err := os.Stat(monoPath); err == nil {
return []string{monoPath}
}
return nil
}
return nil
}
// cleanupMutualExclusion best-effort removes the opposite-mode leftovers.
// Failures emit a warning to errOut but never abort the install.
func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) {
for _, victim := range mutualExclusionVictims(dest, mode) {
if err := os.RemoveAll(victim); err != nil {
fmt.Fprintf(errOut, " ⚠️ 互斥清理失败(继续安装) %s: %v\n", victim, err)
continue
}
fmt.Fprintf(out, " × 已清理对面模式残留 %s\n", victim)
}
}
func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
sort.Strings(dests)
for _, dest := range dests {
// 先做互斥清理:装 mono 前先把同级 dingtalk-* 子目录全部干掉
cleanupMutualExclusion(dest, skillSetupModeMono, out, errOut)
if err := os.RemoveAll(dest); err != nil {
fmt.Fprintf(errOut, " ✗ 清理失败 %s: %v\n", dest, err)
skipped++
continue
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
fmt.Fprintf(errOut, " ✗ 父目录创建失败 %s: %v\n", dest, err)
skipped++
continue
}
if err := copyDir(src, dest); err != nil {
fmt.Fprintf(errOut, " ✗ 拷贝失败 %s: %v\n", dest, err)
skipped++
continue
}
fmt.Fprintf(out, " ✓ %s\n", dest)
installed++
}
return installed, skipped, nil
}
// installMultiSkillToHomes installs each subdir of src (dingtalk-*) into
// dest as a sibling skill directory. installed/skipped is counted per
// (agent-home × sub-skill) pair so the user sees granular progress.
func installMultiSkillToHomes(src string, skillNames []string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
sort.Strings(dests)
for _, dest := range dests {
// 互斥清理:装 multi 前先把 dest/dws/ 整个删除(mono 残留)
cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut)
if err := os.MkdirAll(dest, 0o755); err != nil {
fmt.Fprintf(errOut, " ✗ Agent 目录创建失败 %s: %v\n", dest, err)
skipped += len(skillNames)
continue
}
for _, name := range skillNames {
subSrc := filepath.Join(src, name)
subDest := filepath.Join(dest, name)
if err := os.RemoveAll(subDest); err != nil {
fmt.Fprintf(errOut, " ✗ 清理失败 %s: %v\n", subDest, err)
skipped++
continue
}
if err := copyDir(subSrc, subDest); err != nil {
fmt.Fprintf(errOut, " ✗ 拷贝失败 %s: %v\n", subDest, err)
skipped++
continue
}
fmt.Fprintf(out, " ✓ %s\n", subDest)
installed++
}
}
return installed, skipped, nil
}
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, info.Mode())
}
if info.Mode()&os.ModeSymlink != 0 {
// resolve symlink target and copy the underlying file
resolved, err := os.Readlink(path)
if err != nil {
return err
}
if !filepath.IsAbs(resolved) {
resolved = filepath.Join(filepath.Dir(path), resolved)
}
return copyFileContent(resolved, target, info.Mode())
}
return copyFileContent(path, target, info.Mode())
})
}
func copyFileContent(src, dst string, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode&os.ModePerm)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
func isInteractiveTerminal() bool {
return isCharDevice(os.Stdin) && isCharDevice(os.Stdout) && isCharDevice(os.Stderr)
}
func isCharDevice(file *os.File) bool {
if file == nil {
return false
}
fi, err := file.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
+86
View File
@@ -0,0 +1,86 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
dwsroot "github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
)
// resolveSkillSetupSourceOrEmbedded resolves the skill source for `skill
// setup`. An explicit --source or DWS_SKILL_SOURCE is honored as a developer
// override (validated as an on-disk dir). Otherwise it falls back to the skill
// bundle embedded in THIS binary, so a plain `dws skill setup` always installs
// the version shipped with the running binary — upgrading the binary therefore
// refreshes the installed skill, instead of silently reusing a stale copy from
// the current working directory.
//
// The returned cleanup func removes any temp dir created for the embedded
// bundle; it is a no-op when an on-disk source is used. Always call it.
func resolveSkillSetupSourceOrEmbedded(explicit, mode string) (string, func(), error) {
noop := func() {}
explicit = strings.TrimSpace(explicit)
env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE"))
if explicit != "" || env != "" {
dir, err := resolveSkillSetupSource(explicit, mode)
return dir, noop, err
}
return materializeEmbeddedSkillSource(mode)
}
// materializeEmbeddedSkillSource extracts the embedded skills/<mode> subtree
// into a fresh temp dir and returns its path plus a cleanup func. Reusing a
// real directory lets the existing dir-based install/copy logic stay unchanged.
func materializeEmbeddedSkillSource(mode string) (string, func(), error) {
noop := func() {}
sub := "skills/" + mode // embed.FS always uses forward slashes
if _, err := fs.Stat(dwsroot.EmbeddedSkills, sub); err != nil {
return "", noop, fmt.Errorf("内嵌 skill 不含 %q(二进制可能未随 skills/ 重新构建): %w", sub, err)
}
tmp, err := os.MkdirTemp("", "dws-skill-"+mode+"-")
if err != nil {
return "", noop, fmt.Errorf("创建临时 skill 目录失败: %w", err)
}
cleanup := func() { _ = os.RemoveAll(tmp) }
walkErr := fs.WalkDir(dwsroot.EmbeddedSkills, sub, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel := strings.TrimPrefix(strings.TrimPrefix(p, sub), "/")
dst := filepath.Join(tmp, filepath.FromSlash(rel))
if d.IsDir() {
return os.MkdirAll(dst, 0o755)
}
data, readErr := dwsroot.EmbeddedSkills.ReadFile(p)
if readErr != nil {
return readErr
}
if mkErr := os.MkdirAll(filepath.Dir(dst), 0o755); mkErr != nil {
return mkErr
}
return os.WriteFile(dst, data, 0o644)
})
if walkErr != nil {
cleanup()
return "", noop, fmt.Errorf("展开内嵌 skill 到临时目录失败: %w", walkErr)
}
return tmp, cleanup, nil
}
+68
View File
@@ -0,0 +1,68 @@
// 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 (
"os"
"path/filepath"
"testing"
)
// TestMaterializeEmbeddedSkillSourceMono verifies that the mono skill bundle
// baked into the binary can be extracted to a temp dir and is a valid skill
// source root (so `dws skill setup` works with zero local checkout). The
// nested-reference and _common checks guard against the embed dropping nested
// docs or the `all:` prefix being lost (which would silently skip
// dot/underscore dirs).
func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMono)
if err != nil {
t.Fatalf("materializeEmbeddedSkillSource: %v", err)
}
defer cleanup()
if !isSkillSourceRoot(dir, skillSetupModeMono) {
t.Fatalf("extracted dir %s is not a valid mono skill source root", dir)
}
for _, rel := range []string{
"SKILL.md",
filepath.Join("references", "global-reference.md"),
filepath.Join("references", "best_practices", "_common"),
} {
if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
t.Errorf("expected embedded skill to contain %s: %v", rel, err)
}
}
// cleanup must actually remove the temp dir.
cleanup()
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Errorf("cleanup did not remove temp dir %s (err=%v)", dir, err)
}
}
// TestResolveSkillSetupSourceOrEmbeddedFallsBackToEmbedded verifies that with
// no --source and no DWS_SKILL_SOURCE, resolution uses the embedded bundle
// rather than probing the current working directory (the stale-skill footgun).
func TestResolveSkillSetupSourceOrEmbeddedFallsBackToEmbedded(t *testing.T) {
t.Setenv("DWS_SKILL_SOURCE", "")
dir, cleanup, err := resolveSkillSetupSourceOrEmbedded("", skillSetupModeMono)
if err != nil {
t.Fatalf("resolveSkillSetupSourceOrEmbedded: %v", err)
}
defer cleanup()
if !isSkillSourceRoot(dir, skillSetupModeMono) {
t.Fatalf("embedded fallback returned non-source-root dir %s", dir)
}
}
+545
View File
@@ -0,0 +1,545 @@
package app
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestSkillSetupCommandRegistered(t *testing.T) {
root := buildSkillCommand()
var found bool
for _, sub := range root.Commands() {
if sub.Name() == "setup" {
found = true
break
}
}
if !found {
t.Fatalf("dws skill setup not registered as subcommand")
}
}
func TestResolveSkillSetupModeFlagDirect(t *testing.T) {
got, err := resolveSkillSetupMode("mono", true, &bytes.Buffer{})
if err != nil || got != skillSetupModeMono {
t.Fatalf("expected mono no-error, got %q err=%v", got, err)
}
got, err = resolveSkillSetupMode("MULTI", true, &bytes.Buffer{})
if err != nil || got != skillSetupModeMulti {
t.Fatalf("expected multi case-insensitive, got %q err=%v", got, err)
}
if _, err = resolveSkillSetupMode("hybrid", true, &bytes.Buffer{}); err == nil {
t.Fatalf("expected error on invalid mode")
}
}
func TestResolveSkillSetupModeNonInteractiveDefaultsMono(t *testing.T) {
var buf bytes.Buffer
got, err := resolveSkillSetupMode("", true, &buf)
if err != nil || got != skillSetupModeMono {
t.Fatalf("non-interactive empty mode should default to mono, got %q err=%v", got, err)
}
if !strings.Contains(buf.String(), "mono") {
t.Fatalf("expected output to mention mono fallback, got %q", buf.String())
}
}
func TestIsCharDeviceRejectsNilAndRegularFiles(t *testing.T) {
if isCharDevice(nil) {
t.Fatal("nil file must not be treated as interactive")
}
file, err := os.CreateTemp(t.TempDir(), "stdout")
if err != nil {
t.Fatal(err)
}
defer file.Close()
if isCharDevice(file) {
t.Fatal("regular files must not be treated as interactive terminals")
}
}
func TestResolveSkillSetupSourceFindsMonoRoot(t *testing.T) {
tmp := t.TempDir()
monoDir := filepath.Join(tmp, "skills", "mono")
if err := os.MkdirAll(monoDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(monoDir, "SKILL.md"), []byte("# test"), 0o644); err != nil {
t.Fatal(err)
}
got, err := resolveSkillSetupSource(tmp, skillSetupModeMono)
if err != nil {
t.Fatalf("expected to find mono source, got err=%v", err)
}
if got != monoDir {
t.Fatalf("expected %s, got %s", monoDir, got)
}
}
func TestResolveSkillSetupSourceErrorWhenMissing(t *testing.T) {
tmp := t.TempDir()
t.Setenv("DWS_SKILL_SOURCE", "")
// Isolate HOME so the ~/.dws/skills/<mode>/ fallback (added by the release
// pipeline cache work) does not pick up real cached content on the
// developer machine.
t.Setenv("HOME", t.TempDir())
_, err := resolveSkillSetupSource(tmp, skillSetupModeMono)
if err == nil {
t.Fatalf("expected error when source missing")
}
if !strings.Contains(err.Error(), "未找到") {
t.Fatalf("expected 未找到 message, got %v", err)
}
}
func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) {
got, err := resolveSkillSetupTargets("claude", skillSetupModeMono)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 dest, got %d", len(got))
}
if !strings.Contains(got[0], ".claude/skills/dws") {
t.Fatalf("expected .claude/skills/dws path, got %s", got[0])
}
}
func TestResolveSkillSetupTargetsUnknown(t *testing.T) {
if _, err := resolveSkillSetupTargets("nonsense", skillSetupModeMono); err == nil {
t.Fatalf("expected error for unknown target")
}
}
func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
got, err := resolveSkillSetupTargets("claude", skillSetupModeMulti)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 dest, got %d", len(got))
}
if strings.HasSuffix(got[0], "/dws") {
t.Fatalf("multi target must not end with /dws, got %s", got[0])
}
if !strings.HasSuffix(got[0], ".claude/skills") {
t.Fatalf("expected suffix .claude/skills, got %s", got[0])
}
}
func TestInstallSkillToHomesEndToEnd(t *testing.T) {
src := t.TempDir()
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("# test"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(src, "references"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "references", "x.md"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
dst1 := filepath.Join(t.TempDir(), "a", "dws")
dst2 := filepath.Join(t.TempDir(), "b", "dws")
var stdout, stderr bytes.Buffer
installed, skipped, err := installSkillToHomes(src, []string{dst1, dst2}, &stdout, &stderr)
if err != nil {
t.Fatalf("install err: %v", err)
}
if installed != 2 || skipped != 0 {
t.Fatalf("expected installed=2 skipped=0, got %d/%d", installed, skipped)
}
for _, d := range []string{dst1, dst2} {
if _, err := os.Stat(filepath.Join(d, "SKILL.md")); err != nil {
t.Fatalf("missing SKILL.md in %s: %v", d, err)
}
if _, err := os.Stat(filepath.Join(d, "references", "x.md")); err != nil {
t.Fatalf("missing references/x.md in %s: %v", d, err)
}
}
}
// writeMultiSkillSource builds a fake skills/multi/ layout containing N
// dingtalk-* subdirs, each with a SKILL.md and one references/<name>.md
// file. Returns the absolute skill source root.
func writeMultiSkillSource(t *testing.T, names []string) string {
t.Helper()
root := t.TempDir()
for _, n := range names {
sub := filepath.Join(root, n)
if err := os.MkdirAll(filepath.Join(sub, "references"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte("# "+n), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "references", n+".md"), []byte("ref "+n), 0o644); err != nil {
t.Fatal(err)
}
}
return root
}
func TestInstallMultiSkillToHomes(t *testing.T) {
names := []string{"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc"}
src := writeMultiSkillSource(t, names)
got, err := listMultiSkillNames(src)
if err != nil {
t.Fatalf("listMultiSkillNames err: %v", err)
}
if len(got) != len(names) {
t.Fatalf("expected %d skills, got %d (%v)", len(names), len(got), got)
}
dst1 := filepath.Join(t.TempDir(), ".claude", "skills")
dst2 := filepath.Join(t.TempDir(), ".cursor", "skills")
var stdout, stderr bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, got, []string{dst1, dst2}, &stdout, &stderr)
if err != nil {
t.Fatalf("installMultiSkillToHomes err: %v", err)
}
if installed != len(names)*2 || skipped != 0 {
t.Fatalf("expected installed=%d skipped=0, got %d/%d (stderr=%q)", len(names)*2, installed, skipped, stderr.String())
}
for _, d := range []string{dst1, dst2} {
for _, n := range names {
sub := filepath.Join(d, n)
if _, err := os.Stat(filepath.Join(sub, "SKILL.md")); err != nil {
t.Fatalf("missing %s/SKILL.md: %v", sub, err)
}
if _, err := os.Stat(filepath.Join(sub, "references", n+".md")); err != nil {
t.Fatalf("missing %s/references/%s.md: %v", sub, n, err)
}
}
// dws/ should NOT exist (multi mode is pure siblings)
if _, err := os.Stat(filepath.Join(d, "dws")); err == nil {
t.Fatalf("unexpected dws/ subdir in multi-mode install at %s", d)
}
}
}
func TestSkillSetupMutualExclusion(t *testing.T) {
names := []string{"dingtalk-aitable", "dingtalk-calendar"}
src := writeMultiSkillSource(t, names)
// Simulate a pre-existing mono install under <agent-home>/dws/
agentHome := filepath.Join(t.TempDir(), ".claude", "skills")
monoLeftover := filepath.Join(agentHome, "dws")
if err := os.MkdirAll(filepath.Join(monoLeftover, "references"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(monoLeftover, "SKILL.md"), []byte("old mono"), 0o644); err != nil {
t.Fatal(err)
}
// Sanity: leftover exists before
if _, err := os.Stat(monoLeftover); err != nil {
t.Fatalf("setup: mono leftover should exist before, err=%v", err)
}
// Confirm mutualExclusionVictims sees the leftover
victims := mutualExclusionVictims(agentHome, skillSetupModeMulti)
if len(victims) != 1 || victims[0] != monoLeftover {
t.Fatalf("expected victims=[%s], got %v", monoLeftover, victims)
}
var stdout, stderr bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr)
if err != nil {
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
}
if installed != len(names) || skipped != 0 {
t.Fatalf("expected installed=%d skipped=0, got %d/%d", len(names), installed, skipped)
}
// mono leftover should be gone
if _, err := os.Stat(monoLeftover); !os.IsNotExist(err) {
t.Fatalf("expected mono leftover removed, stat err=%v", err)
}
// multi skills should be in place
for _, n := range names {
if _, err := os.Stat(filepath.Join(agentHome, n, "SKILL.md")); err != nil {
t.Fatalf("missing %s/%s/SKILL.md: %v", agentHome, n, err)
}
}
// the cleanup line should appear in stdout (best-effort observability)
if !strings.Contains(stdout.String(), "已清理对面模式残留") {
t.Fatalf("expected cleanup log line, got stdout=%q", stdout.String())
}
// Now test the reverse: pre-existing multi → installing mono cleans dingtalk-*
monoSrc := t.TempDir()
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
t.Fatal(err)
}
monoDest := filepath.Join(agentHome, "dws")
stdout.Reset()
stderr.Reset()
installed2, skipped2, err := installSkillToHomes(monoSrc, []string{monoDest}, &stdout, &stderr)
if err != nil {
t.Fatalf("mono install err: %v", err)
}
if installed2 != 1 || skipped2 != 0 {
t.Fatalf("expected mono installed=1 skipped=0, got %d/%d", installed2, skipped2)
}
// All dingtalk-* siblings should be gone after mono install
for _, n := range names {
if _, err := os.Stat(filepath.Join(agentHome, n)); !os.IsNotExist(err) {
t.Fatalf("expected %s removed by mutual exclusion, stat err=%v", n, err)
}
}
if _, err := os.Stat(filepath.Join(monoDest, "SKILL.md")); err != nil {
t.Fatalf("mono SKILL.md missing: %v", err)
}
if !strings.Contains(stdout.String(), "已清理对面模式残留") {
t.Fatalf("expected cleanup log line on mono install, got stdout=%q", stdout.String())
}
}
// TestSkillSourceCandidatesIncludesUserCache verifies that the user-level
// cache populated by install.sh / install.ps1 / npm install.js is part of the
// fallback candidate list, so `dws skill setup` can find a source on a fresh
// machine without --source.
func TestSkillSourceCandidatesIncludesUserCache(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Fatalf("UserHomeDir error = %v", err)
}
for _, subdir := range []string{"mono", "multi"} {
got := skillSourceCandidates("", subdir)
want := filepath.Join(home, ".dws", "skills", subdir)
found := false
for _, c := range got {
if c == want {
found = true
break
}
}
if !found {
t.Fatalf("skillSourceCandidates(%q) missing %q; got %v", subdir, want, got)
}
}
}
// TestResolveSkillSetupSourceFallsBackToUserCache verifies that when no
// --source / DWS_SKILL_SOURCE / source checkout is available, the resolver
func TestNormalizeMultiSkillName(t *testing.T) {
cases := []struct {
in, want string
}{
{"aitable", "dingtalk-aitable"},
{"dingtalk-aitable", "dingtalk-aitable"},
{" Calendar ", "dingtalk-calendar"},
{"DINGTALK-DOC", "dingtalk-doc"},
{"", ""},
{" ", ""},
}
for _, c := range cases {
if got := normalizeMultiSkillName(c.in); got != c.want {
t.Errorf("normalizeMultiSkillName(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestFilterMultiSkillNames(t *testing.T) {
all := []string{"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc", "dingtalk-live"}
t.Run("no filter returns all", func(t *testing.T) {
got, err := filterMultiSkillNames(all, nil, nil)
if err != nil {
t.Fatal(err)
}
if len(got) != len(all) {
t.Fatalf("expected %d, got %v", len(all), got)
}
})
t.Run("include short names", func(t *testing.T) {
got, err := filterMultiSkillNames(all, []string{"aitable", "calendar"}, nil)
if err != nil {
t.Fatal(err)
}
if strings.Join(got, ",") != "dingtalk-aitable,dingtalk-calendar" {
t.Fatalf("got %v", got)
}
})
t.Run("include full names", func(t *testing.T) {
got, err := filterMultiSkillNames(all, []string{"dingtalk-doc"}, nil)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0] != "dingtalk-doc" {
t.Fatalf("got %v", got)
}
})
t.Run("include dedups", func(t *testing.T) {
got, err := filterMultiSkillNames(all, []string{"aitable", "dingtalk-aitable", "AITABLE"}, nil)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0] != "dingtalk-aitable" {
t.Fatalf("got %v", got)
}
})
t.Run("include unknown errors with available list", func(t *testing.T) {
_, err := filterMultiSkillNames(all, []string{"aitable", "bogus"}, nil)
if err == nil {
t.Fatal("expected error")
}
msg := err.Error()
if !strings.Contains(msg, "bogus") {
t.Errorf("error should mention bad name, got: %s", msg)
}
if !strings.Contains(msg, "dingtalk-calendar") {
t.Errorf("error should list available names, got: %s", msg)
}
})
t.Run("exclude short names", func(t *testing.T) {
got, err := filterMultiSkillNames(all, nil, []string{"live", "doc"})
if err != nil {
t.Fatal(err)
}
if strings.Join(got, ",") != "dingtalk-aitable,dingtalk-calendar" {
t.Fatalf("got %v", got)
}
})
t.Run("exclude unknown errors", func(t *testing.T) {
_, err := filterMultiSkillNames(all, nil, []string{"bogus"})
if err == nil {
t.Fatal("expected error")
}
})
t.Run("exclude all errors", func(t *testing.T) {
_, err := filterMultiSkillNames(all, nil, []string{"aitable", "calendar", "doc", "live"})
if err == nil {
t.Fatal("expected error when exclude drops everything")
}
if !strings.Contains(err.Error(), "全部") {
t.Errorf("expected 全部 in error, got: %s", err.Error())
}
})
t.Run("include + exclude mutually exclusive", func(t *testing.T) {
_, err := filterMultiSkillNames(all, []string{"aitable"}, []string{"doc"})
if err == nil {
t.Fatal("expected error when both given")
}
})
}
// TestSkillSetupMultiAdditivePreservesSiblings verifies the key UX promise of
// `dws skill setup --mode multi -s aitable`: installing a subset must NOT
// touch already-installed dingtalk-* siblings (additive semantics, matches
// lark-cli `npx skills add -s lark-calendar`).
func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
src := writeMultiSkillSource(t, []string{
"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc",
})
agentHome := filepath.Join(t.TempDir(), ".claude", "skills")
// Pretend the user already installed two dingtalk-* skills earlier.
preExisting := []string{"dingtalk-chat", "dingtalk-todo"}
for _, n := range preExisting {
dir := filepath.Join(agentHome, n)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("OLD "+n), 0o644); err != nil {
t.Fatal(err)
}
}
// User now runs `... --mode multi -s aitable -s calendar`.
filtered, err := filterMultiSkillNames(
[]string{"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc"},
[]string{"aitable", "calendar"},
nil,
)
if err != nil {
t.Fatalf("filter err: %v", err)
}
var stdout, stderr bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, filtered, []string{agentHome}, &stdout, &stderr)
if err != nil {
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
}
if installed != 2 || skipped != 0 {
t.Fatalf("expected installed=2 skipped=0, got %d/%d", installed, skipped)
}
// Asked-for skills should be in place.
for _, n := range []string{"dingtalk-aitable", "dingtalk-calendar"} {
if _, err := os.Stat(filepath.Join(agentHome, n, "SKILL.md")); err != nil {
t.Errorf("missing newly-installed %s: %v", n, err)
}
}
// Unselected source skill must NOT be installed.
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-doc")); !os.IsNotExist(err) {
t.Errorf("dingtalk-doc was not requested but appeared (stat err=%v)", err)
}
// Pre-existing sibling skills must be UNTOUCHED — additive semantics.
for _, n := range preExisting {
body, err := os.ReadFile(filepath.Join(agentHome, n, "SKILL.md"))
if err != nil {
t.Errorf("pre-existing %s was wiped (err=%v)", n, err)
continue
}
if !strings.HasPrefix(string(body), "OLD ") {
t.Errorf("pre-existing %s content changed: got %q", n, string(body))
}
}
}
// TestRunSkillSetupRejectsSkillFlagInMonoMode verifies that the new
// -s/--skill and -x/--exclude flags are gated on --mode multi.
func TestRunSkillSetupRejectsSkillFlagInMonoMode(t *testing.T) {
cmd := newSkillSetupCommand()
cmd.SetArgs([]string{"--mode", "mono", "--yes", "--skill", "aitable"})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for --skill in mono mode")
}
if !strings.Contains(err.Error(), "multi") {
t.Fatalf("error should mention multi gating, got: %v", err)
}
}
func TestResolveSkillSetupSourceMultiFinds(t *testing.T) {
tmp := t.TempDir()
multiDir := filepath.Join(tmp, "skills", "multi")
for _, n := range []string{"dingtalk-aitable", "dingtalk-doc"} {
if err := os.MkdirAll(filepath.Join(multiDir, n), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(multiDir, n, "SKILL.md"), []byte("# "+n), 0o644); err != nil {
t.Fatal(err)
}
}
got, err := resolveSkillSetupSource(tmp, skillSetupModeMulti)
if err != nil {
t.Fatalf("expected to find multi source, got err=%v", err)
}
if got != multiDir {
t.Fatalf("expected %s, got %s", multiDir, got)
}
}
+50
View File
@@ -0,0 +1,50 @@
// 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 (
"os"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
// TestMain isolates the on-disk keychain storage to a process-wide
// temporary directory for the entire internal/app test binary.
//
// Background: getCachedRuntimeToken caches the auth token via sync.Once
// for the process lifetime. Whichever test triggers it first locks in the
// cached value. Several tests in this package (e.g. TestSkillInstallInvalidTarget)
// call SaveTokenData and then exec a CLI command that triggers Once.Do; if
// keychain storage points at the developer's real ~/Library/Application
// Support/dws-cli (or ~/.local/share/dws-cli on Linux), a real token can be
// written there and cached process-wide, breaking later tests that assume
// "no auth" — most notably TestRuntimeRunnerRejectsUnauthenticatedRequest.
//
// Setting keychain.StorageDirEnv here forces every keychain read/write in
// this binary into a per-process tempdir, eliminating that contamination
// without touching production code.
func TestMain(m *testing.M) {
tmpDir, err := os.MkdirTemp("", "dws-app-test-keychain-")
if err != nil {
panic("create test keychain tempdir: " + err.Error())
}
if err := os.Setenv(keychain.StorageDirEnv, tmpDir); err != nil {
_ = os.RemoveAll(tmpDir)
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
}
code := m.Run()
_ = os.RemoveAll(tmpDir)
os.Exit(code)
}
+18 -4
View File
@@ -15,9 +15,10 @@ package app
import (
"context"
"encoding/json"
"log/slog"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -56,6 +57,19 @@ func (a *toolCallerAdapter) DryRun() bool {
func convertResult(r executor.Result) *edition.ToolResult {
resp := r.Response
if resp == nil {
// After the fix-wukong-discovery-missing-servers Phase 3 change,
// runtimeRunner.Run returns an explicit error for catalog misses
// instead of an empty Response, so this branch should only be
// reachable for unit tests / unexpected runners. Log a warning so
// any future regression (silent `{"Content": null}` on the CLI)
// leaves a trace in the file logger / stderr.
slog.Warn(
"tool_caller_adapter: empty runner response — upstream should surface an error instead",
"product", r.Invocation.CanonicalProduct,
"tool", r.Invocation.Tool,
"kind", r.Invocation.Kind,
"dry_run", r.Invocation.DryRun,
)
return &edition.ToolResult{}
}
@@ -63,7 +77,7 @@ func convertResult(r executor.Result) *edition.ToolResult {
contentRaw, ok := resp["content"]
if !ok {
// Dry-run or echo mode: serialize the whole response as text.
data, _ := json.Marshal(resp)
data, _ := jsonutil.Marshal(resp)
return &edition.ToolResult{
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
}
@@ -84,12 +98,12 @@ func convertResult(r executor.Result) *edition.ToolResult {
}
return &edition.ToolResult{Content: blocks}
case map[string]any:
data, _ := json.Marshal(v)
data, _ := jsonutil.Marshal(v)
return &edition.ToolResult{
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
}
default:
data, _ := json.Marshal(contentRaw)
data, _ := jsonutil.Marshal(contentRaw)
return &edition.ToolResult{
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
}
+113 -14
View File
@@ -7,25 +7,28 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
"github.com/fatih/color"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
var (
ugBold = color.New(color.Bold).SprintFunc()
ugGreen = color.New(color.FgGreen).SprintFunc()
ugYellow = color.New(color.FgYellow).SprintFunc()
ugRed = color.New(color.FgRed).SprintFunc()
ugCyan = color.New(color.FgCyan).SprintFunc()
ugDim = color.New(color.Faint).SprintFunc()
ugBoldGrn = color.New(color.Bold, color.FgGreen).SprintFunc()
ugBold = tui.Bold
ugGreen = tui.Success
ugYellow = tui.Warning
ugRed = tui.Danger
ugCyan = tui.Cyan
ugDim = tui.Dim
ugBoldGrn = tui.Success
)
const defaultListLimit = 10
@@ -54,10 +57,20 @@ func newUpgradeCommand() *cobra.Command {
dws upgrade --list --all # 列出所有版本
dws upgrade --version v1.0.5 # 升级到指定版本
dws upgrade --rollback # 回滚到上一版本
dws upgrade --dry-run # 仅预览升级步骤,不实际执行
dws upgrade -y # 跳过确认直接升级`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if h := edition.Get(); h != nil && h.IsEmbedded {
name := h.Name
if name == "" {
name = "embedded"
}
return fmt.Errorf("当前运行在嵌入模式(%s),dws upgrade 已禁用;请通过宿主完成升级", name)
}
yes, _ := cmd.Flags().GetBool("yes")
dryRun, _ := cmd.Flags().GetBool("dry-run")
format := resolveUpgradeFormat(cmd)
if flagList {
@@ -78,6 +91,7 @@ func newUpgradeCommand() *cobra.Command {
force: flagForce,
skipSkills: flagSkipSkills,
yes: yes,
dryRun: dryRun,
})
},
}
@@ -98,6 +112,7 @@ type upgradeOptions struct {
force bool
skipSkills bool
yes bool
dryRun bool
}
// --- dws upgrade --check ---
@@ -288,6 +303,29 @@ func runUpgradeRollback(yes bool) error {
// Phase 2 (Apply): replace binary + install skills — only runs if Phase 1 fully succeeds.
// If anything fails in Phase 1, no files on disk are modified.
// writeDryRunPlan renders the steps that `dws upgrade` would perform, without
// touching the filesystem. Kept side-effect-free and writer-injectable so the
// --dry-run contract can be asserted in tests.
func writeDryRunPlan(w io.Writer, currentVer, binaryAssetName string, hasSkills bool) {
fmt.Fprintln(w)
fmt.Fprintf(w, " %s 预览模式,不会下载或修改任何文件\n", ugBold("[dry-run]"))
fmt.Fprintf(w, " 将执行以下操作:\n")
fmt.Fprintf(w, " [1/5] 备份当前版本 %s\n", ugDim(ensureV(currentVer)))
fmt.Fprintf(w, " [2/5] 下载 %s\n", ugCyan(binaryAssetName))
if hasSkills {
fmt.Fprintf(w, " 下载 %s\n", ugCyan("dws-skills.zip"))
}
fmt.Fprintf(w, " [3/5] 校验 SHA256\n")
fmt.Fprintf(w, " [4/5] 解压并验证\n")
replaceStep := "替换二进制"
if hasSkills {
replaceStep += " 并安装技能包"
}
fmt.Fprintf(w, " [5/5] %s\n", replaceStep)
fmt.Fprintln(w)
fmt.Fprintf(w, " %s\n", ugDim("移除 --dry-run 以实际执行升级"))
}
func runUpgrade(ctx context.Context, opts upgradeOptions) error {
fmt.Printf(" %s\n", ugDim("检查更新..."))
@@ -329,6 +367,20 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
fmt.Printf(" %s %s\n", ugBold("通道: "), ugYellow("pre-release"))
}
// --dry-run: preview only. Resolve the platform asset so a missing build is
// still reported, then describe the steps that *would* run and return before
// any side effect (no backup, no download, no replace). Matches the global
// flag's contract: "预览操作内容,不实际执行".
if opts.dryRun {
binaryAsset, err := upgrade.FindBinaryAsset(release.Assets)
if err != nil {
return err
}
hasSkills := upgrade.FindSkillsAsset(release.Assets) != nil && !opts.skipSkills
writeDryRunPlan(os.Stdout, currentVer, binaryAsset.Name, hasSkills)
return nil
}
if !opts.yes {
fmt.Println()
fmt.Printf("是否升级? [y/N] ")
@@ -508,6 +560,16 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
fmt.Printf(" %s\n", ugGreen("✓"))
}
// Clear discovery-derived caches so the upgraded binary rebuilds its
// command tree from a fresh fetch instead of inheriting snapshots written
// by the old version — a poisoned snapshot used to lock out every
// invocation before the build guards landed (#447 / #449).
if purged, purgeErr := cacheStoreFromEnv().PurgeDiscoveryData(); purgeErr != nil {
fmt.Printf(" %s %s\n", ugYellow("⚠"), ugDim(fmt.Sprintf("清理发现缓存失败 (可手动运行 dws cache refresh): %v", purgeErr)))
} else if len(purged) > 0 {
fmt.Printf(" %s %s\n", ugGreen("✓"), ugDim("发现缓存已清空, 新版本首次运行时自动重建"))
}
// Cleanup old backups
rm.Cleanup(5)
@@ -571,13 +633,18 @@ func validateNewBinary(binaryPath, expectedVersion string) error {
return fmt.Errorf("设置执行权限失败: %w", err)
}
// Try running the binary
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, binaryPath, "version").CombinedOutput()
out, err := tryExecVersion(binaryPath)
if err != nil {
return fmt.Errorf("二进制无法执行: %w", err)
// Apple Silicon kills unsigned arm64 binaries with SIGKILL via amfid.
// Repair the binary in-place (ad-hoc codesign + drop quarantine) and retry once.
if runtime.GOOS == "darwin" && isLikelyAMFIKill(err) {
if repairErr := repairDarwinBinary(binaryPath); repairErr == nil {
out, err = tryExecVersion(binaryPath)
}
}
if err != nil {
return fmt.Errorf("二进制无法执行: %w", err)
}
}
if !strings.Contains(string(out), expectedVersion) {
@@ -587,6 +654,38 @@ func validateNewBinary(binaryPath, expectedVersion string) error {
return nil
}
func tryExecVersion(binaryPath string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return exec.CommandContext(ctx, binaryPath, "version").CombinedOutput()
}
// isLikelyAMFIKill returns true when err looks like macOS amfid SIGKILL'ing an
// unsigned binary. Go reports this as "signal: killed".
func isLikelyAMFIKill(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "signal: killed") || strings.Contains(msg, "signal: kill")
}
// repairDarwinBinary applies an ad-hoc codesign and clears the quarantine xattr.
// Used as a self-heal step when an unsigned binary is killed by amfid on Apple Silicon.
func repairDarwinBinary(binaryPath string) error {
// Best-effort: strip quarantine. Failure is fine (attribute often absent).
_ = exec.Command("xattr", "-d", "com.apple.quarantine", binaryPath).Run()
if _, err := exec.LookPath("codesign"); err != nil {
return fmt.Errorf("codesign 不可用: %w", err)
}
out, err := exec.Command("codesign", "--force", "--sign", "-", binaryPath).CombinedOutput()
if err != nil {
return fmt.Errorf("codesign 失败: %v: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
// extractTarGz extracts a .tar.gz file using the system tar command.
func extractTarGz(archivePath, destDir string) error {
os.MkdirAll(destDir, 0755)
@@ -0,0 +1,69 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"bytes"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestUpgradeCommand_BlockedWhenEmbedded(t *testing.T) {
prev := edition.Get()
edition.Override(&edition.Hooks{IsEmbedded: true, Name: "embedded"})
t.Cleanup(func() { edition.Override(prev) })
cases := []struct {
name string
args []string
}{
{"check", []string{"--check"}},
{"list", []string{"--list"}},
{"rollback", []string{"--rollback"}},
{"plain", []string{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd := newUpgradeCommand()
var out, errBuf bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errBuf)
cmd.SetArgs(tc.args)
err := cmd.Execute()
if err == nil {
t.Fatalf("upgrade %v in embedded mode must return error, got nil", tc.args)
}
msg := err.Error()
if !strings.Contains(msg, "嵌入模式") {
t.Errorf("error message should mention 嵌入模式, got: %q", msg)
}
if !strings.Contains(msg, "embedded") {
t.Errorf("error message should include edition name, got: %q", msg)
}
if !strings.Contains(msg, "dws upgrade") {
t.Errorf("error message should reference dws upgrade for clarity, got: %q", msg)
}
})
}
}
func TestUpgradeCommand_NotBlockedInOpenSourceMode(t *testing.T) {
prev := edition.Get()
edition.Override(&edition.Hooks{IsEmbedded: false, Name: "open"})
t.Cleanup(func() { edition.Override(prev) })
cmd := newUpgradeCommand()
var out, errBuf bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errBuf)
cmd.SetArgs([]string{"--check"})
err := cmd.Execute()
if err != nil && strings.Contains(err.Error(), "嵌入模式") {
t.Errorf("open-source mode must not be blocked by embedded guard, got: %v", err)
}
}
+134
View File
@@ -7,8 +7,11 @@ import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
@@ -427,4 +430,135 @@ func TestNewUpgradeCommand_Help(t *testing.T) {
if !strings.Contains(help, "--rollback") {
t.Error("help should contain --rollback")
}
// Regression for #364: --dry-run must be discoverable from upgrade help so
// users know it is supported (and is now actually honored).
if !strings.Contains(help, "--dry-run") {
t.Error("help should advertise --dry-run for upgrade")
}
}
// --- writeDryRunPlan (#364) ---
//
// Regression for #364: `dws upgrade --dry-run` previously performed a real
// upgrade because the flag was silently ignored. The dry-run path must now be
// preview-only — it describes the steps without downloading or replacing
// anything. writeDryRunPlan is the side-effect-free renderer for that preview.
func TestWriteDryRunPlan_PreviewOnly(t *testing.T) {
var buf bytes.Buffer
writeDryRunPlan(&buf, "v1.0.30", "dws-darwin-arm64.tar.gz", false)
out := buf.String()
if !strings.Contains(out, "dry-run") {
t.Errorf("output should be marked as dry-run, got:\n%s", out)
}
if !strings.Contains(out, "不会下载或修改任何文件") {
t.Errorf("output should state nothing is downloaded or modified, got:\n%s", out)
}
if !strings.Contains(out, "dws-darwin-arm64.tar.gz") {
t.Errorf("output should name the resolved platform asset, got:\n%s", out)
}
// All five steps should be previewed, including the (skipped) replace step.
for _, step := range []string{"[1/5]", "[2/5]", "[3/5]", "[4/5]", "[5/5]"} {
if !strings.Contains(out, step) {
t.Errorf("output missing step %s, got:\n%s", step, out)
}
}
}
func TestWriteDryRunPlan_WithSkills(t *testing.T) {
var buf bytes.Buffer
writeDryRunPlan(&buf, "v1.0.30", "dws-linux-amd64.tar.gz", true)
out := buf.String()
if !strings.Contains(out, "dws-skills.zip") {
t.Errorf("with skills, output should mention dws-skills.zip, got:\n%s", out)
}
if !strings.Contains(out, "安装技能包") {
t.Errorf("with skills, replace step should mention installing skills, got:\n%s", out)
}
// Without skills, neither should appear.
var buf2 bytes.Buffer
writeDryRunPlan(&buf2, "v1.0.30", "dws-linux-amd64.tar.gz", false)
if strings.Contains(buf2.String(), "dws-skills.zip") {
t.Errorf("without skills, output should not mention dws-skills.zip, got:\n%s", buf2.String())
}
}
// --- isLikelyAMFIKill ---
func TestIsLikelyAMFIKill(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"nil error", nil, false},
{"signal killed (real Go format)", errors.New("signal: killed"), true},
{"signal kill variant", errors.New("signal: kill"), true},
{"unrelated error", errors.New("exit status 1"), false},
{"file not found", errors.New("no such file or directory"), false},
{"wrapped killed in middle", errors.New("exec: signal: killed: cleanup"), true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isLikelyAMFIKill(tt.err); got != tt.want {
t.Errorf("isLikelyAMFIKill(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
// --- validateNewBinary self-heal (darwin only) ---
//
// On macOS, an unsigned arm64 binary is SIGKILL'd by amfid. This test verifies
// validateNewBinary recovers via repairDarwinBinary (ad-hoc codesign) and
// successfully re-executes the binary. We use go itself as a stand-in for the
// new dws binary — it's a real signed Mach-O we can strip and re-sign.
func TestValidateNewBinary_RecoversFromUnsignedDarwin(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("amfid SIGKILL only happens on macOS")
}
if _, err := exec.LookPath("codesign"); err != nil {
t.Skip("codesign not available")
}
// Build a fresh dws binary into a temp dir.
tmpDir := t.TempDir()
bin := filepath.Join(tmpDir, "dws-test")
// Locate repo root from this test file's location.
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
repoRoot := filepath.Join(wd, "..", "..")
cmd := exec.Command("go", "build", "-o", bin, "./cmd")
cmd.Dir = repoRoot
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("go build failed: %v\n%s", err, out)
}
// Strip signature to reproduce the unsigned state from CI cross-compilation.
if out, err := exec.Command("codesign", "--remove-signature", bin).CombinedOutput(); err != nil {
t.Fatalf("strip signature: %v\n%s", err, out)
}
// Sanity: confirm direct exec is killed.
if _, err := tryExecVersion(bin); err == nil {
t.Skip("unsigned binary executed without amfid kill — likely Intel Mac or SIP disabled")
}
// validateNewBinary should self-heal and succeed.
if err := validateNewBinary(bin, "dev"); err != nil {
t.Fatalf("validateNewBinary did not recover: %v", err)
}
// Verify the binary now has an ad-hoc signature.
out, _ := exec.Command("codesign", "-dv", bin).CombinedOutput()
if !strings.Contains(string(out), "Signature=adhoc") {
t.Errorf("expected adhoc signature, got: %s", out)
}
}
+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 app
import (
"bytes"
"strings"
"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
}
// TestRenderRootHelpIncludesLong guards that renderRootHelp surfaces the
// root command's Long description in `dws --help` output. The custom
// SetHelpFunc in root_help.go replaces cobra's default help template, which
// previously caused root.Long to be silently dropped. The production
// root.Long carries the "use 'dws upgrade' if a command is missing or
// failing" hint that AI agents rely on when they cannot find a suitable
// command — if this test fails after a help-rendering change, agents will
// silently lose that guidance.
func TestRenderRootHelpIncludesLong(t *testing.T) {
const sentinel = "SENTINEL-LONG-MUST-APPEAR-IN-HELP"
root := &cobra.Command{
Use: "dws",
Long: sentinel,
}
var out bytes.Buffer
root.SetOut(&out)
renderRootHelp(root)
if !strings.Contains(out.String(), sentinel) {
t.Fatalf("renderRootHelp must render root.Long verbatim in --help output; got:\n%s", out.String())
}
}
+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.
// agent_code_detect.go resolves the agent_code — which agent HOST is driving
// dws (claudecode / qoder / cursor / vscode / openclaw / hermes / ...). It
// fills the x-dingtalk-dws-agent-code header for per-channel statistics.
//
// SEPARATE axis from DWS_CHANNEL / x-dws-channel (a distribution channel code);
// the two are never conflated here.
//
// Design contract — ACCURACY OVER COVERAGE, but maximize accurate coverage:
// - Prefer generalizable, host-declared signals so one rule covers a whole
// family (VSCODE_BRAND covers every VS Code fork, present and future).
// - Every per-host signature below is OBSERVED on a real host (live process
// env via `ps eww`, or the app bundle Info.plist), not guessed.
// - Anything unidentified stays empty — never guess or synthesize a PAT key.
// - Deliberately NOT used: TERM_PROGRAM (reports the terminal, e.g. iTerm,
// not the agent host) and fuzzy parent-process name matching.
package auth
import (
"os"
"strings"
)
// AgentCodeCustom is the literal code a host may explicitly declare for a
// custom integration. It is not used as an implicit fallback.
const AgentCodeCustom = "custom"
// hostSignature is a verified env fingerprint for a known agent host. EnvKeys
// match when any listed key is present and non-empty.
type hostSignature struct {
Code string
EnvKeys []string
}
// knownSignatures: CLI / daemon agents that inject a distinctive env var, which
// the dws subprocess they spawn inherits. All verified on a real machine
// (2026-06-16) via live process env / launch env — not guessed.
var knownSignatures = []hostSignature{
// Claude Code — verified: CLAUDECODE=1, CLAUDE_CODE_ENTRYPOINT=cli.
{Code: "claudecode", EnvKeys: []string{"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"}},
// OpenClaw — verified on the running daemon: OPENCLAW_BUNDLE_ROOT.
{Code: "openclaw", EnvKeys: []string{"OPENCLAW_BUNDLE_ROOT", "OPENCLAW_RUNTIME_ROLE"}},
// Hermes — verified on the running gateway: HERMES_HOME.
{Code: "hermes", EnvKeys: []string{"HERMES_HOME"}},
// OpenAI Codex — CODEX_SANDBOX is auto-set by Codex for the subprocesses it
// spawns (e.g. CODEX_SANDBOX=seatbelt on macOS), and Codex filters this
// CODEX_-prefixed name out of user .env to prevent spoofing — so its
// presence reliably means "running under Codex".
// Source: developers.openai.com/codex/concepts/sandboxing
{Code: "codex", EnvKeys: []string{"CODEX_SANDBOX"}},
}
// NOTE on coverage limits (honest, not a TODO to silently ignore):
// Most terminal agents (gemini-cli/antigravity, aider, opencode, qwen-code,
// crush, goose, kimi, amazon-q, continue, ...) expose NO reliable
// self-identifying env marker — only user-set API-key/config vars, which we
// must not key off (a user setting GEMINI_API_KEY is not "running under
// gemini"). They therefore resolve to empty unless they declare themselves.
//
// The authoritative, fully-general path to 100% coverage is the T0 declaration
// contract: a host sets DINGTALK_DWS_AGENTCODE=<code> when it launches dws.
// That is accurate for ANY agent (present or future) on ANY OS, and is what an
// integrating host should wire up. Auto-detection (signatures / VSCODE_BRAND /
// bundle id) is a best-effort supplement for hosts that have not declared.
// bundleIDToCode maps macOS app bundle identifiers to agent codes. The bundle
// id is exposed via __CFBundleIdentifier and inherited by child processes the
// IDE spawns (including dws), so it identifies the host even from an integrated
// terminal. Verified from each app's Info.plist (2026-06-16). Only known agent
// bundles map; everything else (iTerm, Terminal, ...) falls through to empty.
//
// macOS-only signal: __CFBundleIdentifier does not exist on Linux/Windows, so
// this map is simply a no-op there (os.Getenv returns "").
var bundleIDToCode = map[string]string{
"com.qoder.ide": "qoder",
"com.todesktop.230313mzl4w4u92": "cursor", // Cursor's ToDesktop bundle id
"com.microsoft.VSCode": "vscode",
"com.workbuddy.workbuddy": "workbuddy",
}
// DetectAgentCode resolves the agent_code via a confidence ladder and returns
// the normalized code plus the signal that decided it:
//
// T0 explicit host declaration (DINGTALK_DWS_AGENTCODE — dedicated field)
// T1 verified per-agent env signature (CLI/daemon agents)
// T2 VSCODE_BRAND value (every VS Code fork declares its brand)
// T3 macOS app bundle id (known agent bundles only)
// T4 unresolved -> empty (never guess)
func DetectAgentCode() (code string, signal string) {
// T0: host explicitly declares its agent_code — highest confidence.
if v, name := AgentCodeFromEnv(); v != "" {
return v, "env:" + name
}
// T1: verified per-agent env signature (most specific — wins over the IDE
// it may be running inside).
for _, sig := range knownSignatures {
for _, k := range sig.EnvKeys {
if strings.TrimSpace(os.Getenv(k)) != "" {
return sig.Code, "sig:" + k
}
}
}
// T2: VS Code fork family. The brand value IS the host's self-declaration,
// so this single rule covers Qoder/Cursor/VS Code/Windsurf/Trae/Kiro/... —
// including forks that don't exist yet.
if b := strings.TrimSpace(os.Getenv("VSCODE_BRAND")); b != "" {
return normalizeAgentCode(b), "env:VSCODE_BRAND"
}
// T3: macOS app bundle id (known agent bundles only).
if id := strings.TrimSpace(os.Getenv("__CFBundleIdentifier")); id != "" {
if c, ok := bundleIDToCode[id]; ok {
return c, "bundle:" + id
}
}
// T4: unknown host — leave agent_code empty, no guessing.
return "", ""
}
// normalizeAgentCode maps host-declared names/brands to canonical agent_code
// values. Unrecognized but non-empty input is lowercased, space-stripped and
// kept as-is — still a host declaration, so still accurate (this is what gives
// automatic coverage of new VS Code forks via VSCODE_BRAND).
func normalizeAgentCode(raw string) string {
s := strings.ToLower(strings.TrimSpace(raw))
s = strings.ReplaceAll(s, " ", "")
switch s {
case "":
return ""
case "claude", "claude-code", "claude_code", "claudecode":
return "claudecode"
case "qoder":
return "qoder"
case "qoderwork":
return "QoderWork"
case "workbuddy", "work-buddy":
return "workbuddy"
case "visualstudiocode", "code", "code-oss", "vscode":
return "vscode"
case "cursor":
return "cursor"
case "windsurf":
return "windsurf"
case "trae", "traecn":
return "trae"
default:
return s
}
}
+186
View File
@@ -0,0 +1,186 @@
// 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 (
"strings"
"testing"
)
// agentCodeSignalEnvs is every env DetectAgentCode consults. Tests clear them
// all so each case starts clean (the suite itself runs under a real host).
var agentCodeSignalEnvs = []string{
AgentCodeEnv,
"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT",
"OPENCLAW_BUNDLE_ROOT", "OPENCLAW_RUNTIME_ROLE",
"HERMES_HOME", "CODEX_SANDBOX",
"VSCODE_BRAND", "__CFBundleIdentifier",
"TERM_PROGRAM", "DWS_CHANNEL",
}
func clearAgentCodeEnv(t *testing.T) {
t.Helper()
for _, k := range agentCodeSignalEnvs {
t.Setenv(k, "")
}
}
func TestDetectAgentCode_HostDeclaration_T0(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv(AgentCodeEnv, "QoderWork")
code, sig := DetectAgentCode()
if code != "QoderWork" {
t.Fatalf("want verbatim QoderWork, got %q", code)
}
if !strings.HasPrefix(sig, "env:"+AgentCodeEnv) {
t.Fatalf("want env signal, got %q", sig)
}
}
func TestDetectAgentCode_VerifiedSignatures_T1(t *testing.T) {
cases := []struct {
env, val, want string
}{
{"CLAUDECODE", "1", "claudecode"},
{"CLAUDE_CODE_ENTRYPOINT", "cli", "claudecode"},
{"OPENCLAW_BUNDLE_ROOT", "/Users/x/.openclaw-bundle", "openclaw"},
{"HERMES_HOME", "/Users/x/.hermes", "hermes"},
{"CODEX_SANDBOX", "seatbelt", "codex"},
}
for _, c := range cases {
t.Run(c.env, func(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv(c.env, c.val)
code, sig := DetectAgentCode()
if code != c.want {
t.Fatalf("%s=%s: want %q, got %q", c.env, c.val, c.want, code)
}
if !strings.HasPrefix(sig, "sig:") {
t.Fatalf("want sig:* signal, got %q", sig)
}
})
}
}
func TestDetectAgentCode_VSCodeBrand_T2(t *testing.T) {
cases := map[string]string{
"Qoder": "qoder",
"Cursor": "cursor",
"Visual Studio Code": "vscode",
"Windsurf": "windsurf",
"Trae": "trae",
"SomeNewFork": "somenewfork", // generic coverage of future forks
}
for brand, want := range cases {
t.Run(brand, func(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv("VSCODE_BRAND", brand)
code, sig := DetectAgentCode()
if code != want {
t.Fatalf("VSCODE_BRAND=%q: want %q, got %q", brand, want, code)
}
if sig != "env:VSCODE_BRAND" {
t.Fatalf("want env:VSCODE_BRAND signal, got %q", sig)
}
})
}
}
func TestDetectAgentCode_BundleID_T3(t *testing.T) {
cases := map[string]string{
"com.qoder.ide": "qoder",
"com.todesktop.230313mzl4w4u92": "cursor",
"com.microsoft.VSCode": "vscode",
"com.workbuddy.workbuddy": "workbuddy",
}
for id, want := range cases {
t.Run(id, func(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv("__CFBundleIdentifier", id)
code, sig := DetectAgentCode()
if code != want {
t.Fatalf("bundle %q: want %q, got %q", id, want, code)
}
if !strings.HasPrefix(sig, "bundle:") {
t.Fatalf("want bundle:* signal, got %q", sig)
}
})
}
}
// An unknown bundle id (e.g. a plain terminal) must NOT be labeled.
func TestDetectAgentCode_UnknownBundleIsEmpty(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv("__CFBundleIdentifier", "com.googlecode.iterm2")
code, _ := DetectAgentCode()
if code != "" {
t.Fatalf("unknown bundle must be empty, got %q", code)
}
}
func TestDetectAgentCode_FallbackEmpty(t *testing.T) {
clearAgentCodeEnv(t)
code, sig := DetectAgentCode()
if code != "" {
t.Fatalf("want empty code, got %q", code)
}
if sig != "" {
t.Fatalf("want empty signal, got %q", sig)
}
}
// TERM_PROGRAM and DWS_CHANNEL must never decide agent_code.
func TestDetectAgentCode_IgnoresNoise(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv("TERM_PROGRAM", "iTerm.app")
t.Setenv("DWS_CHANNEL", "Qoderwork")
code, _ := DetectAgentCode()
if code != "" {
t.Fatalf("noise must not decide agent_code; want empty, got %q", code)
}
}
// Precedence: explicit declaration (T0) > env signature (T1) > VSCODE_BRAND
// (T2). A CLI agent running inside an IDE reports the CLI agent.
func TestDetectAgentCode_Precedence(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv("CLAUDECODE", "1") // T1
t.Setenv("VSCODE_BRAND", "Qoder") // T2
if code, _ := DetectAgentCode(); code != "claudecode" {
t.Fatalf("T1 must beat T2, got %q", code)
}
t.Setenv(AgentCodeEnv, "workbuddy") // T0
if code, _ := DetectAgentCode(); code != "workbuddy" {
t.Fatalf("T0 must beat all, got %q", code)
}
}
func TestNormalizeAgentCode(t *testing.T) {
cases := map[string]string{
"claude": "claudecode",
"Claude-Code": "claudecode",
"CLAUDECODE": "claudecode",
"Qoderwork": "QoderWork",
"WorkBuddy": "workbuddy",
"Visual Studio Code": "vscode",
"Cursor": "cursor",
"": "",
"some-new-ide": "some-new-ide",
}
for in, want := range cases {
if got := normalizeAgentCode(in); got != want {
t.Errorf("normalizeAgentCode(%q) = %q, want %q", in, got, want)
}
}
}
+49 -5
View File
@@ -16,21 +16,31 @@ package auth
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
configpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
const (
// appConfigFile is the filename for storing app credentials.
appConfigFile = "app.json"
// appConfigFile is the filename for the open-source edition's app
// credentials store. Sibling editions get a name-suffixed file via
// config.EditionFileName so two dws binaries sharing the same config
// directory (~/.dws or DWS_CONFIG_DIR) cannot read/write each other's
// credentials. See GetAppConfigPath for the path derivation contract.
appConfigBase = "app"
appConfigExt = ".json"
appConfigFile = appConfigBase + appConfigExt
)
// AppConfig represents the application credentials configuration.
// This is stored in ~/.dws/app.json with the client secret securely stored in keychain.
// This is stored in the edition-specific app config file, with the client
// secret securely stored in keychain when present.
type AppConfig struct {
ClientID string `json:"clientId"`
ClientSecret SecretInput `json:"clientSecret"`
@@ -53,9 +63,14 @@ var (
cachedResolvedMu sync.RWMutex
)
// GetAppConfigPath returns the path to the app config file.
// GetAppConfigPath returns the path to the app config file for the
// currently-active edition. The filename is partitioned by edition so that
// two dws binaries from different editions sharing the same configDir
// (typically ~/.dws or DWS_CONFIG_DIR) cannot read or overwrite each
// other's credentials. Open-source stays on "app.json" for backwards
// compatibility; sibling editions land on "app-<edition>.json".
func GetAppConfigPath(configDir string) string {
return filepath.Join(configDir, appConfigFile)
return filepath.Join(configDir, configpkg.EditionFileName(edition.Get().Name, appConfigBase, appConfigExt))
}
// LoadAppConfig loads the app configuration from disk.
@@ -105,6 +120,7 @@ func SaveAppConfig(configDir string, config *AppConfig) error {
if err := helpers.AtomicWriteJSON(path, append(data, '\n')); err != nil {
return fmt.Errorf("writing app config: %w", err)
}
cleanupLegacySiblingAppConfig(configDir, config)
// Update cache
cachedAppConfigMu.Lock()
@@ -121,6 +137,34 @@ func SaveAppConfig(configDir string, config *AppConfig) error {
return nil
}
func cleanupLegacySiblingAppConfig(configDir string, config *AppConfig) {
if config == nil || config.ClientID == "" || configpkg.IsOpenEdition(edition.Get().Name) {
return
}
legacyPath := filepath.Join(configDir, appConfigFile)
if legacyPath == GetAppConfigPath(configDir) {
return
}
data, err := os.ReadFile(legacyPath)
if err != nil {
return
}
var legacy AppConfig
if err := json.Unmarshal(data, &legacy); err != nil {
return
}
if legacy.ClientID != config.ClientID {
return
}
if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) {
slog.Debug("auth: best-effort cleanup of legacy app config failed", "path", legacyPath, "error", err)
}
}
// DeleteAppConfig removes the app configuration and associated keychain secrets.
func DeleteAppConfig(configDir string) error {
// Load existing config to clean up keychain
+256
View File
@@ -0,0 +1,256 @@
// 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"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// Verifies that two dws binaries from different editions sharing the same
// configDir (e.g. ~/.dws via DWS_CONFIG_DIR) read and write disjoint
// app.json files. Without partitioning, a sibling edition's post-login
// persistence path could leak its pinned ClientID into the open-source
// build by reading the shared file.
func TestGetAppConfigPath_OpenEditionUsesLegacyName(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() { edition.Override(prev) })
for _, name := range []string{"", "open"} {
edition.Override(&edition.Hooks{Name: name})
got := GetAppConfigPath("/tmp/cfg")
want := filepath.Join("/tmp/cfg", "app.json")
if got != want {
t.Fatalf("edition=%q: GetAppConfigPath = %q, want %q", name, got, want)
}
}
}
func TestGetAppConfigPath_SiblingEditionUsesSuffixedName(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() { edition.Override(prev) })
cases := []struct {
editionName string
wantFile string
}{
{"wukong", "app-wukong.json"},
{"dev", "app-dev.json"},
{"embedded", "app-embedded.json"},
}
for _, tc := range cases {
edition.Override(&edition.Hooks{Name: tc.editionName})
got := GetAppConfigPath("/tmp/cfg")
want := filepath.Join("/tmp/cfg", tc.wantFile)
if got != want {
t.Fatalf("edition=%q: GetAppConfigPath = %q, want %q", tc.editionName, got, want)
}
}
}
func TestGetAppConfigPath_OpenAndSiblingAreDisjoint(t *testing.T) {
// End-to-end invariant: when the same configDir is observed from two
// different editions, the resulting app.json paths must NOT collide.
prev := edition.Get()
t.Cleanup(func() { edition.Override(prev) })
const cfg = "/tmp/shared-cfg"
edition.Override(&edition.Hooks{Name: "open"})
openPath := GetAppConfigPath(cfg)
edition.Override(&edition.Hooks{Name: "wukong"})
wukongPath := GetAppConfigPath(cfg)
if openPath == wukongPath {
t.Fatalf("open and wukong editions share path %q; cross-edition leakage possible", openPath)
}
if filepath.Dir(openPath) != filepath.Dir(wukongPath) {
t.Fatalf("paths landed in different directories (%q vs %q); partitioning should only differ by filename", filepath.Dir(openPath), filepath.Dir(wukongPath))
}
}
func TestAppConfigIO_OpenEditionDoesNotReadSiblingCredentials(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
edition.Override(&edition.Hooks{Name: "wukong"})
wukongPath := GetAppConfigPath(configDir)
if err := os.WriteFile(wukongPath, []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}`+"\n"), 0600); err != nil {
t.Fatalf("writing sibling app config: %v", err)
}
edition.Override(&edition.Hooks{Name: "open"})
got, err := LoadAppConfig(configDir)
if err != nil {
t.Fatalf("LoadAppConfig(open) error = %v", err)
}
if got != nil {
t.Fatalf("open edition read sibling app config: %#v", got)
}
}
func TestSaveAppConfig_SiblingEditionRemovesMatchingLegacyAppConfig(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
legacyPath := filepath.Join(configDir, appConfigFile)
legacyJSON := []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
t.Fatalf("writing legacy app config: %v", err)
}
edition.Override(&edition.Hooks{Name: "wukong"})
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
}
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatalf("matching legacy app config should be removed, stat error = %v", err)
}
if _, err := os.Stat(filepath.Join(configDir, "app-wukong.json")); err != nil {
t.Fatalf("sibling app config not written: %v", err)
}
}
func TestSaveAppConfig_SiblingEditionKeepsDifferentLegacyAppConfig(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
legacyPath := filepath.Join(configDir, appConfigFile)
legacyJSON := []byte(`{"clientId":"open-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
t.Fatalf("writing legacy app config: %v", err)
}
edition.Override(&edition.Hooks{Name: "wukong"})
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
}
got, err := os.ReadFile(legacyPath)
if err != nil {
t.Fatalf("different legacy app config should be preserved: %v", err)
}
if string(got) != string(legacyJSON) {
t.Fatalf("legacy app config changed: got %q, want %q", got, legacyJSON)
}
}
func TestSaveAppConfig_SiblingEditionKeepsMalformedLegacyAppConfig(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
legacyPath := filepath.Join(configDir, appConfigFile)
legacyJSON := []byte(`{"clientId":"wukong-cid"`)
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
t.Fatalf("writing malformed legacy app config: %v", err)
}
edition.Override(&edition.Hooks{Name: "wukong"})
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
}
got, err := os.ReadFile(legacyPath)
if err != nil {
t.Fatalf("malformed legacy app config should be preserved: %v", err)
}
if string(got) != string(legacyJSON) {
t.Fatalf("malformed legacy app config changed: got %q, want %q", got, legacyJSON)
}
}
func TestSaveAppConfig_OpenEditionDoesNotCleanSiblingAppConfigs(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
siblingFiles := map[string][]byte{
"app-wukong.json": []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n"),
"app-dev.json": []byte(`{"clientId":"dev-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n"),
}
for name, data := range siblingFiles {
if err := os.WriteFile(filepath.Join(configDir, name), data, 0600); err != nil {
t.Fatalf("writing sibling app config %s: %v", name, err)
}
}
edition.Override(&edition.Hooks{Name: "open"})
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "open-cid"}); err != nil {
t.Fatalf("SaveAppConfig(open) error = %v", err)
}
for name, want := range siblingFiles {
got, err := os.ReadFile(filepath.Join(configDir, name))
if err != nil {
t.Fatalf("open edition should preserve sibling app config %s: %v", name, err)
}
if string(got) != string(want) {
t.Fatalf("sibling app config %s changed: got %q, want %q", name, got, want)
}
}
}
func TestSaveAppConfig_SiblingEditionKeepsLegacyAppConfigWhenClientIDEmpty(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
legacyPath := filepath.Join(configDir, appConfigFile)
legacyJSON := []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
t.Fatalf("writing legacy app config: %v", err)
}
edition.Override(&edition.Hooks{Name: "wukong"})
if err := SaveAppConfig(configDir, &AppConfig{}); err != nil {
t.Fatalf("SaveAppConfig(wukong empty client ID) error = %v", err)
}
got, err := os.ReadFile(legacyPath)
if err != nil {
t.Fatalf("legacy app config should be preserved when client ID is empty: %v", err)
}
if string(got) != string(legacyJSON) {
t.Fatalf("legacy app config changed: got %q, want %q", got, legacyJSON)
}
}
+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)
}
}
+57
View File
@@ -330,6 +330,63 @@ func TestBuildTokenData_DefaultExpiry(t *testing.T) {
}
}
func TestParseMCPTokenResponseIncludesCorpName(t *testing.T) {
provider := &OAuthProvider{}
data, err := provider.parseMCPTokenResponse([]byte(`{
"accessToken": "access-123",
"refreshToken": "refresh-456",
"expiresIn": 7200,
"corpId": "ding123",
"corpName": "钉钉(中国)信息技术有限公司"
}`))
if err != nil {
t.Fatalf("parseMCPTokenResponse() error = %v", err)
}
if data.CorpID != "ding123" {
t.Fatalf("corp id = %q, want ding123", data.CorpID)
}
if data.CorpName != "钉钉(中国)信息技术有限公司" {
t.Fatalf("corp name = %q, want 钉钉(中国)信息技术有限公司", data.CorpName)
}
}
func TestParseMCPTokenResponseCorpNameFallbacks(t *testing.T) {
provider := &OAuthProvider{}
for _, tc := range []struct {
name string
body string
want string
}{
{
name: "snake",
body: `{"accessToken":"access","refreshToken":"refresh","expiresIn":7200,"corpId":"ding123","corp_name":"Snake Corp"}`,
want: "Snake Corp",
},
{
name: "orgName",
body: `{"accessToken":"access","refreshToken":"refresh","expiresIn":7200,"corpId":"ding123","orgName":"Org Corp"}`,
want: "Org Corp",
},
} {
t.Run(tc.name, func(t *testing.T) {
data, err := provider.parseMCPTokenResponse([]byte(tc.body))
if err != nil {
t.Fatalf("parseMCPTokenResponse() error = %v", err)
}
if data.CorpName != tc.want {
t.Fatalf("corp name = %q, want %q", data.CorpName, tc.want)
}
})
}
}
func TestBuildAuthURLIncludesTargetCorpID(t *testing.T) {
authURL := buildAuthURL("client-id", "http://127.0.0.1:1234/callback", "ding-target")
if !strings.Contains(authURL, "corpId=ding-target") {
t.Fatalf("auth URL missing target corpId: %s", authURL)
}
}
func buildTokenDataFromResponse(resp tokenResponse) *TokenData {
if resp.AccessToken == "" {
return nil
+60
View File
@@ -0,0 +1,60 @@
// 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 primary per-spawn environment variable the host
// injects to declare "this process is driven by a third-party Agent host,
// render authorization UI yourselves".
AgentCodeEnv = "DINGTALK_DWS_AGENTCODE"
// AgentCodeEnvCompat is a compatibility alias for hosts that shipped the
// reversed prefix before AgentCodeEnv became the public spelling.
AgentCodeEnvCompat = "DWS_DINGTALK_AGENTCODE"
)
// AgentCodeFromEnv returns the effective host agent code and the env name that
// supplied it.
//
// Keep the public env surface intentionally single-spelled. The reversed
// DWS_DINGTALK_AGENTCODE draft name is not consumed, so host-owned PAT mode,
// gateway identity headers, and `pat chmod --agentCode` fallback all agree on
// the same stable signal: DINGTALK_DWS_AGENTCODE.
func AgentCodeFromEnv() (string, string) {
if value := strings.TrimSpace(os.Getenv(AgentCodeEnv)); value != "" {
return value, AgentCodeEnv
}
return "", ""
}
func AgentCodeEnvPresent() bool {
value, _ := AgentCodeFromEnv()
return value != ""
}
// HostOwnsPATFlow reports whether the current process is running under a
// third-party Agent host that will render the PAT authorization card
// itself. The trigger is DINGTALK_DWS_AGENTCODE being non-empty. The CLI
// deliberately does not consult any other signal (DINGTALK_AGENT /
// DWS_CHANNEL / the wire claw-type header) for this decision so that
// server-side routing tags and the host-owned UI contract remain independent
// concerns.
func HostOwnsPATFlow() bool {
return AgentCodeEnvPresent()
}
+319
View File
@@ -0,0 +1,319 @@
package auth
import (
"os"
"path/filepath"
"sync"
"testing"
)
// resetAppConfigCache clears cached app config so tests get a fresh load.
func resetAppConfigCache() {
cachedAppConfigMu.Lock()
cachedAppConfig = nil
cachedAppConfigMu.Unlock()
cachedAppConfigOnce = sync.Once{}
cachedResolvedMu.Lock()
cachedResolvedValid = false
cachedResolvedID = ""
cachedResolvedSecret = ""
cachedResolvedMu.Unlock()
}
// ─── Issue #155: Defensive credential reset ────────────────────────────
//
// These tests verify that both DeviceFlowProvider and OAuthProvider always
// reset credential state and re-fetch clientID from MCP, regardless of what
// previous login methods left in app.json or runtime state.
func TestIssue155V2_OAuthLoginNoSource_ThenDeviceLogin_ResetsAndFetches(t *testing.T) {
// Scenario: OAuth login saved app.json WITHOUT Source field (the original bug).
// Device flow should ignore the stale clientID, reset state, and re-fetch from MCP.
dir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", dir)
t.Setenv("DWS_CLIENT_ID", "")
t.Setenv("DWS_CLIENT_SECRET", "")
SetClientID("")
SetClientSecret("")
resetClientIDFromMCP()
resetAppConfigCache()
t.Cleanup(func() {
SetClientID("")
SetClientSecret("")
resetClientIDFromMCP()
resetAppConfigCache()
})
// Simulate OAuth login: saved app.json with clientId but NO Source field
oauthAppJSON := `{"clientId":"ding-oauth-stale","clientSecret":"","createdAt":"2026-04-24T00:00:00+08:00"}`
if err := os.WriteFile(filepath.Join(dir, appConfigFile), []byte(oauthAppJSON), 0o600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
// Verify precondition: ClientID() loads stale value from app.json
resetAppConfigCache()
gotID := ClientID()
if gotID != "ding-oauth-stale" {
t.Fatalf("precondition: ClientID() = %q, want 'ding-oauth-stale'", gotID)
}
if IsClientIDFromMCP() {
t.Fatal("precondition: IsClientIDFromMCP() should be false for app.json without Source")
}
// Now create a DeviceFlowProvider — it should pick up the stale clientID
provider := NewDeviceFlowProvider(dir, nil)
if provider.clientID != "ding-oauth-stale" {
t.Fatalf("provider.clientID = %q, want 'ding-oauth-stale' (from app.json)", provider.clientID)
}
// Key assertion: after prepareCredentials(), the provider should have
// cleared the stale clientID and be ready for MCP fetch.
// We can't call Login() directly (needs real MCP server), but we can
// verify the reset logic by calling the new method directly.
provider.resetCredentialState()
if provider.clientID != "" {
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
}
if IsClientIDFromMCP() {
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false")
}
}
func TestIssue155V2_LegacyAppJson_ThenDeviceLogin_ResetsAndFetches(t *testing.T) {
// Scenario: Legacy app.json (no Source field at all) exists from an old CLI version.
// Device flow should reset and re-fetch.
dir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", dir)
t.Setenv("DWS_CLIENT_ID", "")
t.Setenv("DWS_CLIENT_SECRET", "")
SetClientID("")
SetClientSecret("")
resetClientIDFromMCP()
resetAppConfigCache()
t.Cleanup(func() {
SetClientID("")
SetClientSecret("")
resetClientIDFromMCP()
resetAppConfigCache()
})
// Write legacy app.json
legacyJSON := `{"clientId":"ding-legacy-old","clientSecret":"","createdAt":"2026-01-01T00:00:00+08:00"}`
if err := os.WriteFile(filepath.Join(dir, appConfigFile), []byte(legacyJSON), 0o600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
resetAppConfigCache()
provider := NewDeviceFlowProvider(dir, nil)
// Verify stale clientID was loaded
if provider.clientID != "ding-legacy-old" {
t.Fatalf("provider.clientID = %q, want 'ding-legacy-old'", provider.clientID)
}
// Reset should clear it
provider.resetCredentialState()
if provider.clientID != "" {
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
}
}
func TestIssue155V2_DirectAppJson_ThenDeviceLogin_ResetsAndFetches(t *testing.T) {
// Scenario: User previously logged in with --client-id + --client-secret (direct mode).
// app.json has a different clientId. Device flow should reset and re-fetch from MCP.
dir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", dir)
t.Setenv("DWS_CLIENT_ID", "")
t.Setenv("DWS_CLIENT_SECRET", "")
SetClientID("")
SetClientSecret("")
resetClientIDFromMCP()
resetAppConfigCache()
t.Cleanup(func() {
SetClientID("")
SetClientSecret("")
resetClientIDFromMCP()
resetAppConfigCache()
})
// Simulate direct-mode app.json with clientSecret stored
if err := SaveAppConfig(dir, &AppConfig{
ClientID: "ding-direct-custom",
ClientSecret: PlainSecret("some-secret"),
}); err != nil {
t.Fatalf("SaveAppConfig error: %v", err)
}
resetAppConfigCache()
provider := NewDeviceFlowProvider(dir, nil)
// Verify the direct clientID was loaded
if provider.clientID != "ding-direct-custom" {
t.Fatalf("provider.clientID = %q, want 'ding-direct-custom'", provider.clientID)
}
// Reset should clear it
provider.resetCredentialState()
if provider.clientID != "" {
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
}
if IsClientIDFromMCP() {
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false")
}
}
func TestIssue155V2_MCPFlagAlreadySet_ThenDeviceLogin_StillResets(t *testing.T) {
// Scenario: MCP flag is already set from a previous device login in same process.
// Device flow should still reset and re-fetch to ensure freshness.
dir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", dir)
t.Setenv("DWS_CLIENT_ID", "")
t.Setenv("DWS_CLIENT_SECRET", "")
// Simulate: MCP flag is already set from previous login
SetClientIDFromMCP("ding-old-mcp")
t.Cleanup(func() {
SetClientID("")
SetClientSecret("")
resetClientIDFromMCP()
resetAppConfigCache()
})
provider := NewDeviceFlowProvider(dir, nil)
if provider.clientID != "ding-old-mcp" {
t.Fatalf("provider.clientID = %q, want 'ding-old-mcp'", provider.clientID)
}
// Reset should clear both clientID and MCP flag
provider.resetCredentialState()
if provider.clientID != "" {
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
}
if IsClientIDFromMCP() {
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false after reset")
}
}
func TestIssue155V2_NoAppJson_DeviceLogin_StillWorks(t *testing.T) {
// Scenario: No app.json exists (first time login). Device flow should work normally.
dir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", dir)
t.Setenv("DWS_CLIENT_ID", "")
t.Setenv("DWS_CLIENT_SECRET", "")
SetClientID("")
SetClientSecret("")
resetClientIDFromMCP()
resetAppConfigCache()
t.Cleanup(func() {
SetClientID("")
SetClientSecret("")
resetClientIDFromMCP()
resetAppConfigCache()
})
provider := NewDeviceFlowProvider(dir, nil)
// clientID should already be empty
if provider.clientID != "" {
t.Fatalf("provider.clientID = %q, want empty (no app.json)", provider.clientID)
}
// Reset should be a no-op but not crash
provider.resetCredentialState()
if provider.clientID != "" {
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
}
if IsClientIDFromMCP() {
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false")
}
}
// ─── OAuthProvider defensive reset (--force login) ─────────────────────
func TestIssue155V2_OAuthForceLogin_ResetsStaleCredentials(t *testing.T) {
// Scenario: Previous login saved app.json with MCP-fetched clientID but
// no Source marker. OAuth --force login should reset and re-fetch.
dir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", dir)
t.Setenv("DWS_CLIENT_ID", "")
t.Setenv("DWS_CLIENT_SECRET", "")
SetClientID("")
SetClientSecret("")
resetClientIDFromMCP()
resetAppConfigCache()
t.Cleanup(func() {
SetClientID("")
SetClientSecret("")
resetClientIDFromMCP()
resetAppConfigCache()
})
// Simulate previous login: app.json with clientId but no Source
staleJSON := `{"clientId":"ding-stale-oauth","clientSecret":"","createdAt":"2026-04-24T00:00:00+08:00"}`
if err := os.WriteFile(filepath.Join(dir, appConfigFile), []byte(staleJSON), 0o600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
resetAppConfigCache()
provider := NewOAuthProvider(dir, nil)
// Verify stale clientID was loaded
if provider.clientID != "ding-stale-oauth" {
t.Fatalf("provider.clientID = %q, want 'ding-stale-oauth'", provider.clientID)
}
// Reset should clear it
provider.resetCredentialState()
if provider.clientID != "" {
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
}
if IsClientIDFromMCP() {
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false")
}
}
func TestIssue155V2_OAuthForceLogin_MCPFlagSet_StillResets(t *testing.T) {
// Scenario: MCP flag is already set. OAuth --force login should still reset.
dir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", dir)
t.Setenv("DWS_CLIENT_ID", "")
t.Setenv("DWS_CLIENT_SECRET", "")
SetClientIDFromMCP("ding-old-mcp-oauth")
t.Cleanup(func() {
SetClientID("")
SetClientSecret("")
resetClientIDFromMCP()
resetAppConfigCache()
})
provider := NewOAuthProvider(dir, nil)
provider.resetCredentialState()
if provider.clientID != "" {
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
}
if IsClientIDFromMCP() {
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false after reset")
}
}
+75 -46
View File
@@ -27,8 +27,8 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/fatih/color"
)
const (
@@ -52,6 +52,7 @@ type DeviceFlowProvider struct {
logger *slog.Logger
Output io.Writer
httpClient *http.Client
NoBrowser bool
}
func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider {
@@ -112,6 +113,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 +125,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"`
@@ -127,18 +146,42 @@ type serviceResult struct {
ErrorMsg string `json:"errorMsg"`
}
// resetCredentialState clears any stale credential state inherited from
// previous login methods (OAuth, PAT, etc.) before device flow falls back to
// MCP-managed credentials.
//
// This is a defensive measure: no matter what a prior login wrote to
// app.json, device flow will re-fetch from MCP and set the correct
// clientIDFromMCP flag, ensuring exchangeCode() uses the MCP proxy path
// (which doesn't require clientSecret). Complete runtime AppKey/AppSecret
// overrides intentionally skip this reset.
func (p *DeviceFlowProvider) resetCredentialState() {
p.clientID = ""
clientMu.Lock()
clientIDFromMCP = false
clientMu.Unlock()
}
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
// Ensure we have a valid client ID (fetch from MCP if not available)
if p.clientID == "" {
if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok {
p.clientID = runtimeClientID
clientMu.Lock()
clientIDFromMCP = false
clientMu.Unlock()
} else {
// Defensive reset: clear any stale credential state from previous login
// methods (OAuth scan, PAT, etc.) so we can re-fetch from MCP. This
// ensures --device login works regardless of what app.json contains.
p.resetCredentialState()
if p.logger != nil {
p.logger.Debug("client ID not configured, fetching from MCP server")
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
}
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
}
p.clientID = mcpClientID
// Mark that clientID is from MCP
SetClientIDFromMCP(mcpClientID)
if p.logger != nil {
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
@@ -171,7 +214,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
}
dfPrintDeviceCodeBox(p.output(), authResp)
if authResp.VerificationURIComplete != "" {
if authResp.VerificationURIComplete != "" && !p.NoBrowser {
if bErr := openBrowser(authResp.VerificationURIComplete); bErr != nil && p.logger != nil {
p.logger.Debug("could not open browser", "error", bErr)
}
@@ -271,16 +314,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
}
@@ -412,10 +458,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:
@@ -425,7 +472,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))
}
}
}
@@ -544,20 +591,21 @@ func truncateBody(body []byte, maxLen int) string {
}
var (
dfBold = color.New(color.Bold).SprintFunc()
dfGreen = color.New(color.FgGreen).SprintFunc()
dfYellow = color.New(color.FgYellow).SprintFunc()
dfRed = color.New(color.FgRed).SprintFunc()
dfCyan = color.New(color.FgCyan).SprintFunc()
dfDim = color.New(color.Faint).SprintFunc()
dfBold = tui.Bold
dfGreen = tui.Success
dfYellow = tui.Warning
dfRed = tui.Danger
dfCyan = tui.Cyan
dfDim = tui.Dim
)
func dfPrintStep(w io.Writer, step int, message string, attempt int) {
label := fmt.Sprintf("Step %d", step)
if attempt > 1 {
_, _ = fmt.Fprintf(w, i18n.T("%s (第 %d 次尝试)\\n"), dfBold(fmt.Sprintf("▶ Step %d: %s", step, message)), attempt)
_, _ = fmt.Fprintf(w, i18n.T("%s %s: %s (第 %d 次尝试)\\n"), tui.StateMark("ok"), dfBold(label), message, attempt)
return
}
_, _ = fmt.Fprintf(w, "%s\n", dfBold(fmt.Sprintf("▶ Step %d: %s", step, message)))
_, _ = fmt.Fprintf(w, "%s %s: %s\n", tui.StateMark("ok"), dfBold(label), message)
}
func dfPrintDeviceCodeBox(w io.Writer, auth *DeviceAuthResponse) {
@@ -583,7 +631,7 @@ func dfPrintDeviceCodeBox(w io.Writer, auth *DeviceAuthResponse) {
func dfPrintBox(w io.Writer, lines []string) {
maxLen := 0
for _, line := range lines {
if l := dfPlainLength(line); l > maxLen {
if l := tui.PlainRuneWidth(line); l > maxLen {
maxLen = l
}
}
@@ -592,38 +640,19 @@ func dfPrintBox(w io.Writer, lines []string) {
}
border := strings.Repeat("─", maxLen+4)
_, _ = fmt.Fprintf(w, " ┌%s┐\n", border)
_, _ = fmt.Fprintf(w, " %s\n", tui.Blue("╭"+border+"╮"))
for _, line := range lines {
pad := maxLen - dfPlainLength(line)
pad := maxLen - tui.PlainRuneWidth(line)
if pad < 0 {
pad = 0
}
_, _ = fmt.Fprintf(w, " │ %s%s │\n", line, strings.Repeat(" ", pad))
_, _ = fmt.Fprintf(w, " %s %s%s %s\n", tui.Blue("│"), line, strings.Repeat(" ", pad), tui.Blue("│"))
}
_, _ = fmt.Fprintf(w, " └%s┘\n", border)
}
func dfPlainLength(s string) int {
inEscape := false
length := 0
for _, r := range s {
if r == '\033' {
inEscape = true
continue
}
if inEscape {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
inEscape = false
}
continue
}
length++
}
return length
_, _ = fmt.Fprintf(w, " %s\n", tui.Blue("╰"+border+"╯"))
}
func dfPrintPollStatus(w io.Writer, count, elapsedSec int) {
_, _ = fmt.Fprintf(w, " %s ", dfDim(fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), count, elapsedSec)))
_, _ = fmt.Fprintf(w, " %s %s ", tui.StateMark("pending"), dfDim(fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), count, elapsedSec)))
}
func dfPrintPollResult(w io.Writer, status, message string) {
+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()
+11 -11
View File
@@ -96,7 +96,7 @@ const (
LogoutContinueURL = "https://login.dingtalk.com"
// MCP API endpoints for CLI authorization management.
DefaultMCPBaseURL = "https://mcp.dingtalk.com"
DefaultMCPBaseURL = config.DefaultMCPBaseURL
CLIAuthEnabledPath = "/cli/cliAuthEnabled"
SuperAdminPath = "/cli/superAdmin"
SendCliAuthApplyPath = "/cli/sendCliAuthApply"
@@ -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:
@@ -125,13 +131,7 @@ func GetDeveloperSettingsURL() string {
// 1. ~/.dws/mcp_url file content (for pre-release environment)
// 2. Default value (https://mcp.dingtalk.com)
func GetMCPBaseURL() string {
mcpURLPath := filepath.Join(getDefaultConfigDir(), "mcp_url")
if data, err := os.ReadFile(mcpURLPath); err == nil {
if url := strings.TrimSpace(string(data)); url != "" {
return url
}
}
return DefaultMCPBaseURL
return config.GetMCPBaseURL()
}
// Runtime overrides set via CLI flags (--client-id, --client-secret).
@@ -290,9 +290,9 @@ 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")
func getCompleteRuntimeCredentials() (clientID, clientSecret string, ok bool) {
clientID, clientSecret = getRuntimeCredentials()
return clientID, clientSecret, strings.TrimSpace(clientID) != "" && strings.TrimSpace(clientSecret) != ""
}
// getDefaultConfigDir returns the default configuration directory.
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import "time"
// MarkAccessTokenStale loads the persisted TokenData, sets ExpiresAt to a past
// instant (preserving access_token and refresh_token), and writes it back. The
// next OAuthProvider.GetAccessToken call will see IsAccessTokenValid() == false
// and proceed to lockedRefresh, exchanging the refresh_token for a fresh
// access_token.
//
// Use this only when the server has rejected the current access_token but the
// local expiry has not yet elapsed (zombie token scenario). It does not delete
// any token material and is safe to call concurrently — actual refresh is
// serialized by lockedRefresh's dual-layer locking.
//
// Returns the original load error when there is no usable token on disk; a
// nil error when there is no access_token to invalidate (no-op).
func MarkAccessTokenStale(configDir string) error {
data, err := LoadTokenData(configDir)
if err != nil {
return err
}
if data == nil || data.AccessToken == "" {
return nil
}
data.ExpiresAt = time.Now().Add(-1 * time.Minute)
return SaveTokenData(configDir, data)
}
+151 -14
View File
@@ -13,17 +13,27 @@
// identity.go manages agent instance identification for tracking.
//
// Each agent installation gets a unique agentId (UUID v4) that persists across
// version upgrades but regenerates on reinstall. This identity is transparently
// injected into MCP HTTP headers for gateway-side data collection.
// Identity has two granularities, both injected into MCP HTTP headers for
// gateway-side statistics:
//
// - machineId: a stable per-install UUID v4 (persists across upgrades,
// regenerates on reinstall). Non-PII.
// - agentId: a per-(machine × agentCode) id derived deterministically from
// machineId + agent_code, so one machine running multiple agent hosts
// (e.g. claudecode + cursor) yields a distinct, idempotent agentId per
// agent_code. Computed client-side — no gateway round-trip required.
//
// The agent_code itself is resolved by DetectAgentCode (agent_code_detect.go).
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/json"
"fmt"
"math/big"
"os"
"path/filepath"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -31,14 +41,34 @@ import (
const identityFile = "identity.json"
// identityVersion is the current on-disk schema version. v1 files (no
// machineId/agents) are migrated transparently on load.
const identityVersion = 2
// AgentEntry records the derived agentId for a single agent_code on this
// machine.
type AgentEntry struct {
AgentID string `json:"agentId"`
FirstSeen string `json:"firstSeen,omitempty"`
Detect string `json:"detect,omitempty"` // signal that decided the agent_code
}
// Identity holds the agent instance identification fields.
//
// AgentID is retained for backward compatibility with v1 readers: on a fresh
// install it is written equal to MachineID, and a v1 file's agentId is migrated
// into MachineID on load.
type Identity struct {
AgentID string `json:"agentId"` // UUID v4, generated at install time
Source string `json:"source"` // data source, default "dws"
Version int `json:"version,omitempty"`
AgentID string `json:"agentId"` // v1 install UUID; == MachineID on v2 installs
MachineID string `json:"machineId,omitempty"` // stable per-install machine seed
Source string `json:"source"` // data source, default "dws"
Agents map[string]*AgentEntry `json:"agents,omitempty"` // agent_code -> derived agentId
}
// Load reads the identity from <configDir>/identity.json.
// Returns nil if the file does not exist or cannot be parsed.
// v1 files are migrated in-memory (machineId backfilled from agentId).
func Load(configDir string) *Identity {
path := filepath.Join(configDir, identityFile)
data, err := os.ReadFile(path)
@@ -49,21 +79,43 @@ func Load(configDir string) *Identity {
if err := json.Unmarshal(data, &id); err != nil {
return nil
}
if id.AgentID == "" {
if id.AgentID == "" && id.MachineID == "" {
return nil
}
id.migrate()
return &id
}
// migrate backfills v2 fields from a v1 file in-memory (does not persist).
func (id *Identity) migrate() {
if id.MachineID == "" {
id.MachineID = id.AgentID // v1 install UUID becomes the machine seed
}
if id.AgentID == "" {
id.AgentID = id.MachineID
}
if id.Source == "" {
id.Source = "dws"
}
if id.Agents == nil {
id.Agents = make(map[string]*AgentEntry)
}
id.Version = identityVersion
}
// EnsureExists loads existing identity or creates a new one if not present.
func EnsureExists(configDir string) *Identity {
if id := Load(configDir); id != nil {
return id
}
u := generateUUID()
id := &Identity{
AgentID: generateUUID(),
Source: "dws",
Version: identityVersion,
AgentID: u, // kept == MachineID for backward-compat
MachineID: u,
Source: "dws",
Agents: make(map[string]*AgentEntry),
}
// Best-effort persist — don't fail the CLI if write fails.
@@ -71,14 +123,51 @@ func EnsureExists(configDir string) *Identity {
return id
}
// Headers returns the identity as HTTP header key-value pairs.
// machineSeed returns the stable seed used to derive per-channel agentIds.
func (id *Identity) machineSeed() string {
if id.MachineID != "" {
return id.MachineID
}
return id.AgentID
}
// ResolveAgentID returns the per-(machine × agentCode) agentId, deriving and
// persisting it on first sight of an agentCode. Idempotent: the same machine
// and agentCode always yields the same id, which is what makes cumulative
// per-agent_code statistics possible. An empty agentCode has no per-agent
// identity and returns empty.
func (id *Identity) ResolveAgentID(configDir, agentCode, signal string) string {
if agentCode == "" {
return ""
}
if id.Agents == nil {
id.Agents = make(map[string]*AgentEntry)
}
if e, ok := id.Agents[agentCode]; ok && e.AgentID != "" {
return e.AgentID
}
aid := deriveAgentID(id.machineSeed(), agentCode)
id.Agents[agentCode] = &AgentEntry{
AgentID: aid,
FirstSeen: time.Now().UTC().Format(time.RFC3339),
Detect: signal,
}
_ = save(configDir, id) // best-effort cache; recomputable if it fails
return aid
}
// Headers returns the identity as static HTTP header key-value pairs.
// x-dws-agent-id carries the stable machine-level id (== v1 install UUID), kept
// continuous across versions. The per-(machine × agent_code) instance id is a
// SEPARATE header (x-dws-agent-instance-id) injected by the caller via
// ResolveAgentID — it does not override x-dws-agent-id.
func (id *Identity) Headers() map[string]string {
if id == nil {
return nil
}
h := make(map[string]string, 5)
if id.AgentID != "" {
h["x-dws-agent-id"] = id.AgentID
if seed := id.machineSeed(); seed != "" {
h["x-dws-agent-id"] = seed
}
if id.Source != "" {
h["x-dws-source"] = id.Source
@@ -104,6 +193,38 @@ func save(configDir string, id *Identity) error {
return os.WriteFile(filepath.Join(configDir, identityFile), data, config.FilePerm)
}
// deriveAgentID computes a stable, client-side agentId for a (machine,
// agentCode) pair: dwsa_<12 base62 chars of sha256(seed|agentCode)>.
// Deterministic and idempotent; no gateway allocation needed for statistics.
func deriveAgentID(seed, agentCode string) string {
sum := sha256.Sum256([]byte(seed + "|" + agentCode))
enc := base62Encode(sum[:])
for len(enc) < 12 {
enc = "0" + enc
}
return "dwsa_" + enc[:12]
}
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
func base62Encode(b []byte) string {
n := new(big.Int).SetBytes(b)
if n.Sign() == 0 {
return "0"
}
base := big.NewInt(62)
mod := new(big.Int)
var out []byte
for n.Sign() > 0 {
n.DivMod(n, base, mod)
out = append(out, base62Alphabet[mod.Int64()])
}
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return string(out)
}
// generateUUID produces a UUID v4 string.
func generateUUID() string {
var u [16]byte
@@ -113,6 +234,22 @@ func generateUUID() string {
}
u[6] = (u[6] & 0x0f) | 0x40 // version 4
u[8] = (u[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
u[0:4], u[4:6], u[6:8], u[8:10], u[10:16])
return fmtUUID(u)
}
func fmtUUID(u [16]byte) string {
const hexdig = "0123456789abcdef"
// 8-4-4-4-12 with dashes => 36 bytes
buf := make([]byte, 36)
pos := 0
for i := 0; i < 16; i++ {
if i == 4 || i == 6 || i == 8 || i == 10 {
buf[pos] = '-'
pos++
}
buf[pos] = hexdig[u[i]>>4]
buf[pos+1] = hexdig[u[i]&0x0f]
pos += 2
}
return string(buf)
}
+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 auth
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDeriveAgentID_Format(t *testing.T) {
id := deriveAgentID("machine-abc", "claudecode")
if !strings.HasPrefix(id, "dwsa_") {
t.Fatalf("want dwsa_ prefix, got %q", id)
}
if len(id) != len("dwsa_")+12 {
t.Fatalf("want 12 base62 chars after prefix, got %q (len %d)", id, len(id))
}
}
func TestDeriveAgentID_Deterministic(t *testing.T) {
a := deriveAgentID("seed", "claudecode")
b := deriveAgentID("seed", "claudecode")
if a != b {
t.Fatalf("derivation must be deterministic: %q != %q", a, b)
}
}
func TestDeriveAgentID_DistinctByChannelAndMachine(t *testing.T) {
m1c1 := deriveAgentID("machine1", "claudecode")
m1c2 := deriveAgentID("machine1", "cursor")
m2c1 := deriveAgentID("machine2", "claudecode")
if m1c1 == m1c2 {
t.Errorf("same machine, different channel must differ: %q", m1c1)
}
if m1c1 == m2c1 {
t.Errorf("different machine, same channel must differ: %q", m1c1)
}
}
func TestResolveAgentID_IdempotentAndPersisted(t *testing.T) {
dir := t.TempDir()
id := EnsureExists(dir)
first := id.ResolveAgentID(dir, "claudecode", "sig:CLAUDECODE")
second := id.ResolveAgentID(dir, "claudecode", "sig:CLAUDECODE")
if first != second {
t.Fatalf("ResolveAgentID must be idempotent: %q != %q", first, second)
}
// Reload from disk — the channel entry must have persisted.
reloaded := Load(dir)
if reloaded == nil {
t.Fatal("expected identity to persist")
}
e, ok := reloaded.Agents["claudecode"]
if !ok || e.AgentID != first {
t.Fatalf("persisted agentId mismatch: %+v", reloaded.Agents)
}
if e.Detect != "sig:CLAUDECODE" {
t.Errorf("want detect signal recorded, got %q", e.Detect)
}
}
func TestResolveAgentID_EmptyAgentCodeReturnsEmpty(t *testing.T) {
dir := t.TempDir()
id := EnsureExists(dir)
got := id.ResolveAgentID(dir, "", "fallback")
if got != "" {
t.Fatalf("empty agent_code must not derive an instance id, got %q", got)
}
}
// A v1 file ({agentId, source}) must migrate: machineId backfilled from the
// legacy agentId, and per-channel derivation keyed off that stable seed.
func TestLoad_MigratesV1(t *testing.T) {
dir := t.TempDir()
v1 := `{"agentId":"504ddd36-3acf-45f6-9c1f-82f99260a419","source":"dws"}`
if err := os.WriteFile(filepath.Join(dir, identityFile), []byte(v1), 0o600); err != nil {
t.Fatal(err)
}
id := Load(dir)
if id == nil {
t.Fatal("v1 file should load")
}
if id.MachineID != "504ddd36-3acf-45f6-9c1f-82f99260a419" {
t.Fatalf("machineId must backfill from legacy agentId, got %q", id.MachineID)
}
if id.machineSeed() != id.MachineID {
t.Fatalf("seed should be machineId, got %q", id.machineSeed())
}
// Derivation is stable against the migrated seed.
want := deriveAgentID(id.MachineID, "claudecode")
if got := id.ResolveAgentID(dir, "claudecode", "sig:CLAUDECODE"); got != want {
t.Fatalf("post-migration derivation mismatch: %q != %q", got, want)
}
}

Some files were not shown because too many files have changed in this diff Show More