Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60ac0b409d | ||
|
|
4bc4b60dca | ||
|
|
31e65dda51 | ||
|
|
387ae5ff59 | ||
|
|
838e5453d8 | ||
|
|
330922cdee | ||
|
|
eaa60f95b5 | ||
|
|
e7a3010b81 | ||
|
|
e7ef2c4677 | ||
|
|
8c2093a41a | ||
|
|
5fbf12fe50 | ||
|
|
dd419ca498 | ||
|
|
f826375556 | ||
|
|
cb95207d5a | ||
|
|
1e95d03606 | ||
|
|
6e3f3cbd24 | ||
|
|
ce5e919c52 | ||
|
|
c75ed45c70 | ||
|
|
a8b1670ad9 | ||
|
|
8c4bd71964 | ||
|
|
539d10f80f | ||
|
|
56a5edecef | ||
|
|
4ebc8d0d38 | ||
|
|
4e58e45d30 | ||
|
|
5ce7cb61b0 | ||
|
|
86ff7e2f50 | ||
|
|
45cb237f74 | ||
|
|
bd711108f9 | ||
|
|
6b4d808d39 | ||
|
|
c7d8ddf98d | ||
|
|
754b0df056 | ||
|
|
6be124777f | ||
|
|
355a1460d9 | ||
|
|
c6edc84e40 | ||
|
|
f497047fff | ||
|
|
a9de7d3ca4 | ||
|
|
1c200d883f | ||
|
|
d268524084 | ||
|
|
ed4673e7d2 | ||
|
|
de723914a5 | ||
|
|
649801e479 | ||
|
|
49c5bea4f3 | ||
|
|
6707e56f9c | ||
|
|
2ba1dcdda4 | ||
|
|
1637ae16c7 | ||
|
|
eee19d7347 | ||
|
|
19f7b59ffb | ||
|
|
c4952d0207 | ||
|
|
ecf2684f58 | ||
|
|
9e9b898dd2 | ||
|
|
17f692e7f1 | ||
|
|
574d9aa2f7 | ||
|
|
1aaaef0274 | ||
|
|
00c037b5be | ||
|
|
9e15115ad4 | ||
|
|
25bf3d12f2 | ||
|
|
f78cc5c846 | ||
|
|
72fe795f3f | ||
|
|
0e892c7d75 | ||
|
|
8995bf65d6 | ||
|
|
91af2bc3b8 | ||
|
|
e2e8b3bf52 | ||
|
|
a652b90fd4 | ||
|
|
7a868ddf39 | ||
|
|
89d7c5f11b | ||
|
|
efb61cae02 | ||
|
|
fb88c6ace9 | ||
|
|
5258959a14 | ||
|
|
c515fc1001 | ||
|
|
7692048cf4 | ||
|
|
426810a776 | ||
|
|
f253841cf7 | ||
|
|
da2522c8ca | ||
|
|
43715e28b0 | ||
|
|
d11aa0f030 | ||
|
|
41396426d0 | ||
|
|
6946929e0b | ||
|
|
6756ead38a | ||
|
|
4c44a2b632 | ||
|
|
c0468754a4 | ||
|
|
e06b1d68ff | ||
|
|
b101d5b12b | ||
|
|
acc5dc8a3c | ||
|
|
592749c986 | ||
|
|
9b6174e03e | ||
|
|
f73fa08e49 | ||
|
|
bb5e5a40d9 | ||
|
|
e3b0c9b1ed | ||
|
|
cc1f0a47e3 | ||
|
|
b0c9160ed5 | ||
|
|
0b544a86b7 | ||
|
|
d71031d097 | ||
|
|
46192d68ba | ||
|
|
eeb1782f7a | ||
|
|
4d0e194861 | ||
|
|
3046ff8e77 | ||
|
|
ad33a46883 | ||
|
|
bbca507792 | ||
|
|
97390829dd | ||
|
|
7828089081 | ||
|
|
a2908d2cbf | ||
|
|
2f935586c8 | ||
|
|
af000a8dfa | ||
|
|
89c6b737c0 | ||
|
|
bfe0e310fe | ||
|
|
f19a3ccfa5 | ||
|
|
89c5038446 | ||
|
|
4f915e4e2c | ||
|
|
ec03b7cca3 | ||
|
|
e0544579d2 | ||
|
|
fa1ab411e3 | ||
|
|
b247ef1266 | ||
|
|
c7ee0491b2 | ||
|
|
ce43280c11 | ||
|
|
74ca40c197 | ||
|
|
cfaa673863 | ||
|
|
3bc6c31a2d | ||
|
|
d06aa618e5 | ||
|
|
9bedec7ed2 | ||
|
|
383aeefaf6 | ||
|
|
a5bede3a19 | ||
|
|
bbf66e23d6 | ||
|
|
0bc5345cf5 | ||
|
|
5e168c92cf | ||
|
|
725577103d | ||
|
|
f762117d4e | ||
|
|
750b6c04d6 | ||
|
|
59e51c348a | ||
|
|
a056a9abfb | ||
|
|
33ae780103 | ||
|
|
daf56514f7 | ||
|
|
8bcbceb971 | ||
|
|
df01f36442 | ||
|
|
b0024aa669 | ||
|
|
7b7aeadbbe | ||
|
|
94ad422a9f | ||
|
|
4f1ee37508 | ||
|
|
fec0347cd6 | ||
|
|
93318f4a83 | ||
|
|
a14fd0250c | ||
|
|
c99e228669 | ||
|
|
95d495f290 | ||
|
|
4bf300d862 | ||
|
|
1a1fc531f5 | ||
|
|
9fc570607f | ||
|
|
4851d19141 | ||
|
|
42fb25d150 | ||
|
|
416ad6571d | ||
|
|
9a119fbd64 | ||
|
|
c5decb2f90 | ||
|
|
fae2a4f5f0 | ||
|
|
d25b106e4f | ||
|
|
9f78e51ae7 | ||
|
|
d2752d8b5b | ||
|
|
8ecbff391c | ||
|
|
d259864a2b | ||
|
|
408098bdc1 | ||
|
|
658ec1676c | ||
|
|
e36d3b3474 | ||
|
|
0b9952c58d | ||
|
|
56af1ea091 | ||
|
|
ea5859b92b | ||
|
|
19f2ed5c69 | ||
|
|
efbaf7a49d | ||
|
|
374a9e9b13 | ||
|
|
d7d85c9e67 | ||
|
|
0fa982fe91 | ||
|
|
c4fb1bbd3e | ||
|
|
26d7d8f946 | ||
|
|
05ac342c4b | ||
|
|
5e491aef8f | ||
|
|
202187d5e2 | ||
|
|
13877b1c3a | ||
|
|
0e72e89ba3 | ||
|
|
f1b68271cc | ||
|
|
83efff21cd | ||
|
|
e6a4b35921 | ||
|
|
cc2d97ddba | ||
|
|
b78dd19cf9 | ||
|
|
1f0a75f836 | ||
|
|
16202c83a3 | ||
|
|
f4cc76c77d | ||
|
|
9fef6a9c43 | ||
|
|
810985b03a | ||
|
|
02633c6bd3 | ||
|
|
eb9416aa16 | ||
|
|
65b64af213 | ||
|
|
f1d160a481 | ||
|
|
f8c7f012a1 | ||
|
|
45618a55e6 | ||
|
|
c49583836b | ||
|
|
9dc8dc7065 | ||
|
|
f978e306cc | ||
|
|
aec852f971 | ||
|
|
143f781064 | ||
|
|
953b422295 | ||
|
|
da1a0f1299 | ||
|
|
bc7d19cfd8 | ||
|
|
df3122090f | ||
|
|
713fdf6188 | ||
|
|
70e21b58b4 | ||
|
|
18ebba1bb2 | ||
|
|
937404e6df | ||
|
|
88e155dd23 | ||
|
|
2e2cea0973 | ||
|
|
9b8c13a8b6 | ||
|
|
8238cc9f41 | ||
|
|
e59c4f30b8 | ||
|
|
fd7ef5edc2 | ||
|
|
a8e1acec09 | ||
|
|
31eb10985e | ||
|
|
1436b62a80 | ||
|
|
ec6a27635b | ||
|
|
1727744691 | ||
|
|
afdd47b5a5 | ||
|
|
d968e8e551 | ||
|
|
c649d1a762 | ||
|
|
a1f5d97345 | ||
|
|
58062515a5 | ||
|
|
5614b508f2 | ||
|
|
5e003a41b1 | ||
|
|
4eaeb1dd4a | ||
|
|
84471bd6f0 | ||
|
|
c8e3ac21c2 | ||
|
|
c38892b7cf | ||
|
|
1a0a5324f0 | ||
|
|
c1e9e9e0d6 | ||
|
|
cc4dd1e87b |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 52.6%"><title>coverage: 52.6%</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">52.6%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">52.6%</text></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 57.5%"><title>coverage: 57.5%</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">57.5%</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">57.5%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">57.5%</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -34,7 +34,7 @@ jobs:
|
||||
body: issue.body,
|
||||
state: issue.state,
|
||||
html_url: issue.html_url,
|
||||
labels: (issue.labels || []).map(label => label.name)
|
||||
labels: (issue.labels || []).map(label => label.name).join(', ') || '无标签'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -27,6 +28,17 @@ jobs:
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- 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:
|
||||
|
||||
@@ -16,6 +16,7 @@ dws
|
||||
test/cli/testdata/
|
||||
tmp/
|
||||
test/cli_compat/testdata/
|
||||
/internal/compat/testdata/*
|
||||
.gitignore
|
||||
.worktrees/
|
||||
|
||||
@@ -28,3 +29,8 @@ credentials*
|
||||
plans
|
||||
_docs
|
||||
dws.zip
|
||||
*.code-workspace
|
||||
/dingtalk-workspace.zip
|
||||
|
||||
# envelope/discovery.pre.json synced via Portal, not git-tracked
|
||||
/envelope/discovery.pre.json
|
||||
|
||||
+732
@@ -4,6 +4,738 @@ 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.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.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws im` alias** — `dws im` is now registered as an alias of `dws chat` for intent clarity
|
||||
- **User-identity messaging** (`chat message send`) — send group or 1-on-1 messages as the current user
|
||||
- Recipient selection is mutually exclusive: `--group <openConversationId>` / `--user <userId>` / `--open-dingtalk-id <openDingTalkId>`
|
||||
- Markdown text via `--text` (or positional arg), optional `--title`
|
||||
- Group-only: `--at-all` to @everyone, `--at-users` for per-member @mentions
|
||||
- Image messages via `--media-id` (obtained from `dt_media_upload`)
|
||||
- **Personal messages** (`chat message send-personal`) — sensitive personal-channel send (⚠️ destructive/dangerous op, requires confirmation)
|
||||
- **Conversation read paths**:
|
||||
- `chat message list` — pull group / 1-on-1 conversation messages
|
||||
- `chat message list-all` — pull all conversations for the current user in a time range
|
||||
- `chat message list-topic-replies` — pull group topic reply threads
|
||||
- `chat message list-by-sender` — messages by a specific sender
|
||||
- `chat message list-mentions` — messages where the current user was @-mentioned
|
||||
- `chat message list-focused` — messages from focused / starred contacts
|
||||
- `chat message list-unread-conversations` — unread conversation list
|
||||
- `chat message search` — keyword search across conversations
|
||||
- `chat message info` — conversation metadata
|
||||
- `chat list-top-conversations` — pinned conversation list
|
||||
- **Group creation & discovery**:
|
||||
- `chat group create-org` — create an organization-wide group
|
||||
- `chat search-common` — search groups shared with a nickname list (`--nicks`, `--match-mode AND|OR`, cursor-based pagination)
|
||||
- **Bot lifecycle**:
|
||||
- `chat bot create` — create an enterprise bot
|
||||
- `chat bot search-groups` — search the groups a bot is present in
|
||||
|
||||
### Changed
|
||||
|
||||
- **`chat` skill reference** (`skills/references/products/chat.md`, #148) restructured into three sub-groups — `group` (9) / `message` (15) / `bot` (3) — with refreshed intent-routing table, workflow examples, and context-passing rules aligned with `dws-service-endpoints.json` (16 new group-chat tool overrides + 2 new bot tool overrides)
|
||||
- **README Key Services** sync:
|
||||
- `Chat` row: 10 → 20 commands; subcommand tags expanded to `message` `group` `search` `list-top-conversations`
|
||||
- `Bot` row: 6 → 7 commands; subcommand tags expanded with `create` `search-groups`
|
||||
- Total raised to **152 commands across 14 products**
|
||||
|
||||
## [1.0.12] - 2026-04-21
|
||||
|
||||
Product-surface expansion: first-class `doc` (DingTalk Docs) and `minutes` (AI Minutes) skill references, refreshed `aitable` guide aligned with the shipped binary (including dashboard / chart / export), and a README sync that brings the full command catalog to **141 commands across 14 products**.
|
||||
|
||||
### Added
|
||||
|
||||
- **`doc` skill reference** (`skills/references/products/doc.md`) — 16-command coverage of DingTalk Docs:
|
||||
- Discovery: `search`, `list`, `info`, `read`
|
||||
- Authoring: `create`, `update`, `folder create`
|
||||
- Files: `upload`, `download`
|
||||
- Block-level editing: block `query`, `insert`, `update`, `delete`
|
||||
- Comments: `comment list`, `create`, `reply`
|
||||
- URL → `doc_id` extraction rules and nodeId dual-format notes
|
||||
- **`minutes` skill reference** (`skills/references/products/minutes.md`) — coverage of AI Minutes:
|
||||
- Lists: personal / shared-with-me / all-accessible
|
||||
- Content: basic info, AI summary, keywords, transcription, extracted todos, batch detail
|
||||
- Editing: title update
|
||||
- Recording control: start, pause, resume, stop
|
||||
- **SKILL.md routing**:
|
||||
- Product overview table rows for `doc` and `minutes`
|
||||
- Intent decision tree routes — `钉钉文档/云文档/知识库/块级编辑/文档评论` → `doc`; `听记/AI听记/会议纪要/转写/摘要/思维导图/发言人/热词` → `minutes`
|
||||
- Danger-op table entries: `doc delete`, `doc block delete`
|
||||
- `aitable` description completed with the `附件` (attachment) group
|
||||
- **`aitable` skill enhancements**:
|
||||
- `field create` single-field mode (`--name` / `--type` / `--config`) with examples
|
||||
- `base get` URL → `baseId` quick-tip
|
||||
- Dedicated "URL → baseId 提取" chapter
|
||||
- "`--filters` 筛选语法排错与使用规范" chapter
|
||||
- "相关产品" cross-link section pointing to `doc`
|
||||
- **"复杂操作" chapter** (#141) — dashboard / chart workflow (with two-call sequencing and `chart share get` vs `dashboard share get` error semantics) and two-stage `export data` polling (`scope=all/table/view` parameter constraints)
|
||||
- **README Key Services sync** (#140):
|
||||
- New rows: `doc` (16 commands), `minutes` (22 commands — adds `hot-word`, `mind-graph`, `replace-text`, `speaker`, `upload` subgroups)
|
||||
- `aitable` expanded from 20 → 37 commands; surfaces `chart`, `dashboard`, `export`, `import`, `view` subgroups
|
||||
- Total command count updated from **86 → 141 across 14 products**
|
||||
- "Coming soon" list drops `doc` and `minutes`
|
||||
|
||||
### Changed
|
||||
|
||||
- `aitable record query` docs rename `--keyword` → `--query` to match the shipped binary
|
||||
- `aitable record query` docs clarify `--sort` direction semantics (avoids misuse of `order`)
|
||||
- `aitable base list` guidance strengthened — "only for recent browsing; use `base search` for lookups"; intent decision prioritizes `base search` for base discovery
|
||||
|
||||
## [1.0.11] - 2026-04-20
|
||||
|
||||
Plugin subsystem hardening: faster cold startup, cleaner lifecycle, stricter isolation, and polished UX for PAT / i18n / error routing.
|
||||
|
||||
### Added
|
||||
|
||||
- `feat: supports claw-like products` — overlay path for Claw-style embedded editions
|
||||
- `feat(plugin): inject user identity (UserID, CorpID) into stdio plugin subprocesses`
|
||||
- `feat(auth): improve login UX for terminal auth denial cases` — clearer messaging + retry affordance
|
||||
- `feat: PAT scope error visualization and auto-retry with authorization polling` (#113)
|
||||
- Human-readable error output (lark-cli style) with type/message/hint/authorization command
|
||||
- JSON payload also available via `--format json`
|
||||
- Auto-retry once the user completes scope authorization
|
||||
|
||||
### Changed
|
||||
|
||||
- `perf(plugin): serve plugin MCP tool list from disk cache on startup` — hot path skips Initialize+ListTools when snapshot exists
|
||||
- `perf(plugin): parallelize all plugin discovery and tighten cold timeouts` — HTTP cold budget 4s → 700ms (auth) / 500ms (plain); stdio and HTTP fan out concurrently
|
||||
- `perf(plugin): share cache.Store across discovery` — single `*cache.Store` above the fan-out instead of per-goroutine instances
|
||||
- `refactor(plugin): remove default/managed plugin privileged mechanism` (#124) — third-party plugins install on an equal footing via `dws plugin install`
|
||||
- `refactor(plugin): purge removed plugin settings instead of merely disabling` — `RemovePlugin` now deletes `EnabledPlugins` and `PluginConfigs` entries
|
||||
|
||||
### Fixed
|
||||
|
||||
- `fix(transport): cap plugin MCP startup at ~4s when endpoints are unreachable` (#119) — eliminates the 10s `dws --help` stall caused by compounding transport timeouts
|
||||
- `fix(plugin): stop stdio child processes on exit and before removal` — no more orphaned plugin subprocesses
|
||||
- `fix(pat): avoid shared PAT command state in root registration` (#129)
|
||||
- `fix: -f json 模式下错误 JSON 从 stdout 改为输出到 stderr` (#133) — restores CI stderr-based failure assertions
|
||||
- `fix(cli): localize plugin/help command strings via i18n` (#118, #134) — zh locale now shows consistent Chinese `--help`; wraps plugin module, help command, and OAuth client-id/secret flag descriptions
|
||||
- `chore: remove workspace and bundled artifacts` (#127) — clean local-only repository leftovers
|
||||
|
||||
## [1.0.9] - 2026-04-16
|
||||
|
||||
Plugin system launch + execution-pipeline overhaul. This is the largest release since 1.0.0: third-party MCP servers become first-class commands, the command pipeline grows to five stages, and the edition overlay gains the hooks needed for embedded hosts.
|
||||
|
||||
### Added
|
||||
|
||||
#### Plugin system (new)
|
||||
|
||||
- `plugin` command family: `install`, `list`, `info`, `enable`, `disable`, `remove`, `create`, `dev`, `config set/get/list/unset`
|
||||
- Plugin manifest parsing/validation, managed/user directory-based identity
|
||||
- MCP server conversion and injection into the dynamic routing registry
|
||||
- Pipeline hook adapter for shell-based hooks
|
||||
- Stdio transport: subprocess lifecycle, `DWS_PLUGIN_ROOT` / `DWS_PLUGIN_DATA` variable expansion
|
||||
- Stdio server tools automatically registered as CLI subcommands (e.g. `dws hello greet --name Peter`)
|
||||
- Streamable-HTTP MCP tool discovery via `registerHTTPServer`
|
||||
- Updater: managed plugin update check on CLI startup (10 s timeout, best-effort)
|
||||
- `dws plugin create` scaffold (plugin.json, SKILL.md, hooks.json); `dws plugin dev` source-dir registration without copy
|
||||
- `SyncSkills` — copies plugin skills to agent directories on startup
|
||||
- **Auth Token Registry**: per-server HTTP headers declared in `plugin.json` for third-party MCP servers (e.g. Alibaba Cloud Bailian) independent from DingTalk OAuth
|
||||
- **Persistent plugin config** (`dws plugin config ...`): values persisted to `~/.dws/settings.json`, auto-injected as env vars; `${KEY}` in `plugin.json` resolves without manual `export`
|
||||
- **Build lifecycle**: `build` field compiles stdio servers to native binaries at install time
|
||||
- **Command-name conflict protection**: reserved built-in names (`auth`, `plugin`, `cache`, …) and plugin-vs-plugin duplicate detection
|
||||
- Parallel service discovery (`sync.WaitGroup`) — startup reduced from sequential `N*10s` to parallel `max(10s)`
|
||||
|
||||
#### Core commands & diagnostics
|
||||
|
||||
- `dws doctor` — one-stop environment/auth/network diagnostics
|
||||
- `dws config list` — centralized view of scattered configuration
|
||||
- Structured perf tracing (upgraded from debug tool to diagnostics output)
|
||||
- `feat(skill): restore find/get for legacy skill market API` — `skill find`, `skill get`; `skill add` still uses aihub download
|
||||
|
||||
#### Edition / overlay hooks
|
||||
|
||||
- `edition.Hooks.SaveToken` / `LoadToken` / `DeleteToken` — delegate token persistence with keychain fallback
|
||||
- `edition.Hooks.AuthClientID` / `AuthClientFromMCP` — overlay can override the OAuth client ID and route auth through MCP endpoints
|
||||
- `edition.Hooks.AfterPersistentPreRun` — wire non-MCP clients (e.g. A2A gateway) after root setup
|
||||
- `edition.Hooks.ClassifyToolResult` — custom MCP result classification before the default business-error detection
|
||||
- Token marker file (`token.json`) for embedded hosts to detect auth state without keychain access
|
||||
- `pkg/runtimetoken.ResolveAccessToken` mirroring MCP auth resolution; MCP identity headers exported via `pkg/cli` for auxiliary HTTP transports
|
||||
- `ExitCoder` interface — edition-specific errors carry custom exit codes
|
||||
- `RawStderrError` interface — errors that bypass CLI formatting and emit raw stderr (for desktop runtimes)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Command execution pipeline: 3 → 5 stages** (`Register → PreParse → PostParse → PreRequest → PostResponse`)
|
||||
- `feat(schema): return structured degraded errors instead of silent empty catalog` — new `CatalogDegraded` error with reasons `unauthenticated` / `market_unreachable` / `runtime_all_failed`; auth pre-check short-circuits doomed MCP connections
|
||||
- `refactor(auth): unify auxiliary token resolution with MCP cached path` — shared `resolveAccessTokenFromDir`; overlays reuse the process-level token cache
|
||||
- `feat(plugin): improve CLI overlay resolution and plugin install robustness`
|
||||
- `plugin.json` `cli` field now accepts a file path (e.g. `"cli": "overlay.json"`) in addition to inline JSON
|
||||
- `description` field on `CLIToolOverride` for static fallback when MCP `tools/list` is unavailable
|
||||
- Windows install uses `cmd /C` instead of `sh -c` for build commands
|
||||
|
||||
### Fixed
|
||||
|
||||
- `fix(plugin): harden plugin system security boundaries`
|
||||
- Reject `file://` / local paths in git URLs; allow only `https` / `ssh`
|
||||
- Reject symlink entries during ZIP extraction (path-traversal defense)
|
||||
- `build.output` must be a relative path within the plugin directory
|
||||
- Reject absolute paths in stdio command declarations
|
||||
- Block dangerous env var names (`PATH`, `LD_PRELOAD`, …) from plugin config injection
|
||||
- `fix(plugin): schema flag params, HTTP tool discovery, and integration tests`
|
||||
- `fix(plugin): skip min version check in dev mode`
|
||||
|
||||
## [1.0.8] - 2026-04-07
|
||||
|
||||
AITable command surface expansion, installer alignment with npm conventions, and execution-timeout hardening.
|
||||
|
||||
### Added
|
||||
|
||||
- **AITable static helper commands** (20 commands in total) replacing dynamic routing:
|
||||
- `base`: `list`, `search`, `get`, `create`, `update`
|
||||
- `table`: `get`, `create`, `update`
|
||||
- `field`: `get`, `create`, `update`
|
||||
- `record`: `query`, `create`, `update`
|
||||
- `template`: `search`
|
||||
- `attachment`: `upload`
|
||||
- `feat(install): align skill dirs with npm and add OpenClaw` — skill install paths follow npm conventions; OpenClaw added to supported agents
|
||||
- Label rendering optimization for AITable records (`to #73551688`)
|
||||
- README: npm install method documented
|
||||
- README: note that `dws upgrade` requires v1.0.7+
|
||||
|
||||
### Changed
|
||||
|
||||
- `perf: optimize command timeout handling, instrumentation, and diagnostics`
|
||||
|
||||
## [1.0.7] - 2026-04-02
|
||||
|
||||
Self-upgrade, edition overlay foundation, and fail-closed auth enforcement.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws upgrade`** — self-upgrade via GitHub Releases; atomic replace; cross-platform (macOS/Linux/Windows)
|
||||
- `feat: edition layer for Wukong overlay` — build-time edition hook lets downstream overlays customize auth UX, config dir, static server list, visible products, and extra root commands
|
||||
- `pkg/edition` defaults + `pkg/editiontest` contract tests
|
||||
- `Makefile` target `edition-test`; CI job `edition-tests`
|
||||
- Static server injection skips market discovery when configured
|
||||
- Deduplicates top-level commands so overlay wins
|
||||
- `hideNonDirectRuntimeCommands` respects edition `VisibleProducts`
|
||||
- Gated `auth login` subcommand + hints for embedded editions
|
||||
- Optional token auto-purge; edition `ConfigDir` override
|
||||
- `dws version` — human-readable multi-line output plus JSON with edition, architecture, build, commit
|
||||
- Tag reporting for case suites (`to #73551688`)
|
||||
- `feat(auth): unify MCP retry constant and add retry to remaining endpoints`
|
||||
|
||||
### Changed
|
||||
|
||||
- `style(auth): redesign OAuth authorization pages UI`
|
||||
|
||||
### Fixed
|
||||
|
||||
- `fix(auth): switch CLI auth check from fail-open to fail-closed`
|
||||
- When `/cli/cliAuthEnabled` is unreachable (network error/timeout/5xx), OAuth callback now routes to the permission request page instead of silently marking "enabled"
|
||||
- Device Flow blocks login and asks the user to verify network connectivity
|
||||
- `CheckCLIAuthEnabled` retries with backoff (3 attempts, 0s/1s/2s) to tolerate transient issues
|
||||
|
||||
## [1.0.6] - 2026-04-01
|
||||
|
||||
Error diagnostics overhaul, destructive-command confirmation, and credential auto-persistence.
|
||||
|
||||
### Added
|
||||
|
||||
- **Interactive confirmation for destructive dynamic commands** — prompts before delete/remove operations unless `--yes` is set
|
||||
- **Enhanced error diagnostics**
|
||||
- `ServerDiagnostics` struct extracts `trace_id`, `server_error_code`, `technical_detail`, `server_retryable` from MCP responses
|
||||
- Pulls diagnostics from JSON-RPC `error.data`, tool call result content, and HTTP headers (`X-Trace-Id`, `X-Request-Id`, `x-dingtalk-trace-id`)
|
||||
- Three verbosity levels for `PrintHuman`: Normal (trace ID + server code), Verbose (+ technical detail), Debug (+ RPC code / operation / reason)
|
||||
- Local logging now includes sanitized request body, response body on error, retry attempts, and classification events
|
||||
- `TruncateBody` / `SanitizeArguments` / `RedactHeaders` helpers with sensitive-key substring detection
|
||||
- **Auth credential persistence**
|
||||
- `feat(auth): enhance device flow with CLI auth check and admin guidance`
|
||||
- `feat(auth): persist OAuth credentials for reliable token refresh`
|
||||
- `feat(auth): persist client credentials and optimize keychain access` — auto-persist `--client-id` / `--client-secret`; keychain credential cache to avoid repeated reads; enhanced logout cleans `app.json` + keychain secrets + `token.json`
|
||||
- `add report helper with flexible date parsing and defaults`
|
||||
- `feat: to #73551688 支持消息通知`
|
||||
- README: Official App mode (recommended, direct login without creating an app) + Custom App mode; admin guide for enabling CLI access
|
||||
|
||||
### Changed
|
||||
|
||||
- Getting Started simplified with inline login commands; whitelist references removed from the IMPORTANT banner
|
||||
- Version bump documentation updated to v1.0.5 internal; co-creation group QR code refreshed
|
||||
|
||||
### Fixed
|
||||
|
||||
- `fix: resolve verbosity flag lookup, FileLogger lazy binding, and business error logging`
|
||||
- `resolveVerbosity` uses `cmd.Flags()` instead of `PersistentFlags()` so subcommands inherit `--verbose` / `--debug`
|
||||
- `FileLogger` lazy-binds in `executeInvocation` (after `configureLogLevel` init)
|
||||
- Business errors (HTTP 200 + `success=false`) now written to the file logger for offline diagnosis
|
||||
- OAuth callback race condition (write response before sending code)
|
||||
- `import path for errors package in skill_command.go`
|
||||
|
||||
## [1.0.4] - 2026-03-30
|
||||
|
||||
Token-refresh reliability and onboarding clarity.
|
||||
|
||||
### Added
|
||||
|
||||
- `feat(auth): persist client credentials for token refresh` — `--client-id` / `--client-secret` are stored for automatic refresh after expiration; client secret lives in the system Keychain with a file reference
|
||||
- README onboarding flow rewrite with step-by-step first-time setup and more realistic examples
|
||||
- Agent skill reference polish: clearer examples, updated intent routing patterns, expanded `simple.md` onboarding, cross-skill reference fixes
|
||||
|
||||
## [1.0.3] - 2026-03-29
|
||||
|
||||
Filtering power, schema rendering, and a native `todo` command family.
|
||||
|
||||
### Added
|
||||
|
||||
- **Nested / array-indexed output filtering**
|
||||
- `--fields` now accepts dot-notation (e.g. `--fields response.content`) and array index access (e.g. `response.items[0]`)
|
||||
- New field-path parser with recursive extraction logic
|
||||
- **`schema` command enhancements**
|
||||
- Table format output for human consumption
|
||||
- Product-level endpoint loading in the CLI loader
|
||||
- Schema-text rendering wired into the runner output pipeline
|
||||
- **`todo` task helper family** — static `create` / `update` / `done` / `get` / `delete` with `preferLegacyLeaf` replacing dynamic commands
|
||||
- MCP tool alignment: `create_personal_todo`, `update_todo_task`, `update_todo_done_status`, `query_todo_detail`, `delete_todo`
|
||||
- ISO-8601 due-time parsing
|
||||
- Hidden title aliases and delete confirmation
|
||||
- Priority field on `todo` helper
|
||||
- Expanded zh / en i18n coverage (fixes `en.json` spacing/wording issues)
|
||||
- README restructured with collapsible feature sections
|
||||
|
||||
## [1.0.2] - 2026-03-29
|
||||
|
||||
Deep workspace tooling upgrade: pipeline-based input correction, output filtering, enhanced stdin handling, and multi-endpoint routing.
|
||||
|
||||
@@ -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,9 +63,36 @@ 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>
|
||||
|
||||
**npm** (requires Node.js (npm/npx)):
|
||||
|
||||
```bash
|
||||
npm install -g dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
**Pre-built binary**: download from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases).
|
||||
|
||||
> **macOS users**: If you see "cannot be opened because Apple cannot check it for malicious software", run:
|
||||
@@ -88,6 +115,8 @@ cp dws ~/.local/bin/ # install to PATH
|
||||
|
||||
## Upgrade
|
||||
|
||||
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
|
||||
|
||||
dws has built-in self-upgrade capability. Updates are pulled directly from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) with SHA256 integrity verification and automatic backup.
|
||||
|
||||
```bash
|
||||
@@ -174,15 +203,40 @@ Credentials are securely persisted after first login (Keychain). Subsequent runs
|
||||
|
||||
</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:
|
||||
@@ -194,7 +248,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}'
|
||||
@@ -211,32 +265,65 @@ 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/`, ... 18 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
|
||||
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
|
||||
|
||||
```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).
|
||||
|
||||
**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>
|
||||
@@ -263,6 +350,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>
|
||||
|
||||
@@ -273,7 +406,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
|
||||
@@ -313,6 +446,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
|
||||
```
|
||||
|
||||
@@ -335,31 +469,43 @@ 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>
|
||||
|
||||
## Key Services
|
||||
|
||||
| Service | Command | Commands | Subcommands | Description |
|
||||
|---------|---------|:--------:|-------------|-------------|
|
||||
| Contact | `contact` | 6 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
|
||||
| Chat | `chat` | 10 | `message` `group` `search` | Group CRUD, member management, bot messaging, webhook |
|
||||
| Bot | `chat bot` | 6 | `bot` `group` `message` `search` | Robot creation/search, group/single messaging, webhook, message recall |
|
||||
| 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 |
|
||||
| Contact | `contact` | 15 | `user` `dept` `label` `relation` | Search users by name / mobile / job-number, batch query, departments, labels & roles, person relations, roster profile & dismissions, current user |
|
||||
| Chat / IM | `chat` (alias `im`) | 65 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | Messages (send / reply / list / list-all / by-sender / mentions / focused / unread / topic replies / search / advanced search / forward / cards / emoji & text-emotion reactions / recall / read & send status queries), group CRUD + member management (members add / remove / list / `add-bot`, member-role CRUD, invite URL, icon, settings, transfer-owner, set-admin, quit), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common-groups lookup, group/member/conversation mute, conversation set-top, conversation categories |
|
||||
| Calendar | `calendar` | 17 | `event` `room` `participant` `busy` | Events CRUD + suggested times + attachments, meeting room booking, free-busy query, participant management |
|
||||
| Todo | `todo` | 16 | `task` `comment` | Create / list / update / done / get / delete tasks, plus task comments |
|
||||
| Approval | `oa` | 15 | `approval` | Approve / reject / revoke / redirect tasks, pending / initiated / submitted / executed / cc instances, process forms, comments, operation records |
|
||||
| Attendance | `attendance` | 4 | `record` `shift` `summary` `rules` | Clock-in records, shift schedules, attendance summary, group rules |
|
||||
| Ding | `ding` | 2 | `message` | Send/recall DING messages |
|
||||
| Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics |
|
||||
| AITable | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | Full CRUD for bases/tables/records/fields, templates |
|
||||
| Workbench | `workbench` | 2 | `app` | Batch query app details |
|
||||
| DevDoc | `devdoc` | 1 | `article` | Search platform docs and error codes |
|
||||
| Ding | `ding` | 2 | `message` | Send / recall DING messages |
|
||||
| Report | `report` | 20 | `create` `submit` `list` `detail` `template` `stats` `inbox` `outbox` `entry` | Create / submit reports, sent & received (inbox / outbox) lists, templates (get / list), statistics, single-entry get |
|
||||
| AI Tables | `aitable` | 52 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` `form` | Full CRUD for Bases / datasheets / records / fields / views; charts & dashboards with public-share configs; data import/export; attachments (prepare-only `upload` + one-shot `upload-file`); datasheet forms; templates |
|
||||
| Doc | `doc` | 28 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), upload / download |
|
||||
| Drive | `drive` | 9 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` | DingTalk drive file ops: list spaces, list / info / download, create folders, one-shot `upload` (three-step composite) or two-phase `upload-info` + `commit`, delete |
|
||||
| Minutes | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | List AI meeting notes (mine / shared), details (info / summary / keywords / transcription / todos / batch), title/summary updates, mind map, speaker replace, hot-word, upload session |
|
||||
| Mail | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | List mailboxes, KQL message search, read & send messages, drafts, folders, tags, threads, attachments, address-book user search |
|
||||
| Sheet | `sheet` | 23 | `range` `filter-view` (top-level: `create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read / write / append, dimension ops, cell merge / unmerge, find / replace, named filter views + sheet-level filters, image write |
|
||||
| Wiki | `wiki` | 21 | `space` `member` `node` `doc` `file` | Knowledge base management: spaces (`create` / `get` / `list` / `search`), members (`add` / `list` / `update`), node tree, docs & files |
|
||||
| DevDoc | `devdoc` | 2 | `article` `error` | Search Open Platform documentation and troubleshoot API errors |
|
||||
| AI Search | `aisearch` | 3 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
|
||||
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
|
||||
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
|
||||
|
||||
> 86 commands across 12 products. Run `dws --help` for the full list, or `dws <service> --help` for subcommands.
|
||||
> **331 commands across 18 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
|
||||
|
||||
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
|
||||
|
||||
<details>
|
||||
<summary>Coming soon</summary>
|
||||
|
||||
`doc` (documents) · `mail` (email) · `minutes` (AI transcription) · `drive` (cloud drive) · `conference` (video) · `tb` (Teambition) · `aiapp` (AI apps) · `live` (streaming) · `skill` (marketplace)
|
||||
- `conference` (video meetings)
|
||||
- Multi-skill mode (experimental) — per-product skills under `skills/multi/`; opt in via `dws skill setup --mode multi`
|
||||
|
||||
</details>
|
||||
|
||||
@@ -408,6 +554,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
|
||||
## Reference & Docs
|
||||
|
||||
- [Command Index](./docs/command-index.md) — every runtime command with description and when-to-use guidance
|
||||
- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
|
||||
- [Architecture](./docs/architecture.md) — discovery-driven pipeline, IR, transport layer
|
||||
- [Changelog](./CHANGELOG.md) — release history and migration notes
|
||||
|
||||
+171
-28
@@ -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,9 +63,36 @@ 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** | 18 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。18 个独立 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>
|
||||
|
||||
**npm**(需要 Node.js(npm/npx)):
|
||||
|
||||
```bash
|
||||
npm install -g dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
**预编译二进制文件**:从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 下载。
|
||||
|
||||
> **macOS 用户注意**:如果提示“无法打开,因为 Apple 无法检查其是否包含恶意软件”,请执行:
|
||||
@@ -88,6 +115,8 @@ cp dws ~/.local/bin/ # 安装到 PATH
|
||||
|
||||
## 升级
|
||||
|
||||
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
|
||||
|
||||
dws 内置自升级能力,直接从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 拉取更新,支持 SHA256 完整性校验和自动备份。
|
||||
|
||||
```bash
|
||||
@@ -174,15 +203,40 @@ dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
|
||||
|
||||
</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 环境:
|
||||
@@ -194,7 +248,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}'
|
||||
@@ -217,26 +271,56 @@ 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 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>
|
||||
@@ -263,6 +347,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>
|
||||
|
||||
@@ -273,7 +403,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
|
||||
@@ -335,31 +465,43 @@ 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>
|
||||
|
||||
## 核心服务
|
||||
|
||||
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|
||||
|------|------|:------:|--------|------|
|
||||
| 通讯录 | `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` | 同意/拒绝/撤销、待我审批、我发起的、流程列表 |
|
||||
| 通讯录 | `contact` | 15 | `user` `dept` `label` `relation` | 按姓名 / 手机号 / 工号搜索、批量查询、部门树、角色标签、人员关系、花名册与离职、当前用户信息 |
|
||||
| 群聊 | `chat`(别名 `im`)| 65 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | 消息(发送 / 回复 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索 / 高级搜索 / 转发 / 卡片 / 表情与文本表情反应 / 撤回 / 已读与发送状态查询)、群增删改 + 成员管理(成员增 / 删 / 查 / `add-bot`、成员角色增删改查、邀请链接、群图标、群设置、转让群主、设置管理员、退群)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊、群/成员/会话免打扰、会话置顶、会话分类 |
|
||||
| 日历 | `calendar` | 17 | `event` `room` `participant` `busy` | 日程 CRUD + 建议时间 + 附件、会议室预订、闲忙查询、参与者管理 |
|
||||
| 待办 | `todo` | 16 | `task` `comment` | 创建、列表、修改、完成、详情、删除,以及任务评论 |
|
||||
| 审批 | `oa` | 15 | `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` | 搜索开放平台文档与错误码 |
|
||||
| DING | `ding` | 2 | `message` | 发送 / 撤回 DING 消息 |
|
||||
| 日志 | `report` | 20 | `create` `submit` `list` `detail` `template` `stats` `inbox` `outbox` `entry` | 创建 / 提交日志、收发(收件箱 / 发件箱)列表、模版(获取 / 列表)、详情、统计、单条获取 |
|
||||
| AI 表格 | `aitable` | 52 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` `form` | Base / 数据表 / 记录 / 字段 / 视图 全量 CRUD;图表 + 仪表盘(含分享配置);数据导入导出;附件(仅获取凭证的 `upload` + 一键上传 `upload-file`);数据表表单;模板 |
|
||||
| 文档 | `doc` | 28 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | 搜索 / 读写文档、文件与文件夹创建、块级编辑、评论(list / create / reply / create-inline)、上传 / 下载 |
|
||||
| 钉盘 | `drive` | 9 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` | 钉盘文件操作:列出空间、文件列表 / 详情 / 下载、创建文件夹、一键 `upload`(三步合成)或两阶段 `upload-info` + `commit`、删除 |
|
||||
| AI 听记 | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | 听记列表(我创建 / 共享给我)、详情(info / summary / keywords / transcription / todos / batch)、标题/摘要更新、思维导图、发言人替换、热词、上传会话 |
|
||||
| 邮箱 | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | 邮箱地址列表、KQL 邮件搜索、读取与发送邮件、草稿、文件夹、标签、会话、附件、通讯录用户搜索 |
|
||||
| 在线电子表格 | `sheet` | 23 | `range` `filter-view`(顶层:`create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并/取消合并、查找替换、命名筛选视图 + 表级筛选、写入图片 |
|
||||
| 知识库 | `wiki` | 21 | `space` `member` `node` `doc` `file` | 知识库管理:空间(`create` / `get` / `list` / `search`)、成员(`add` / `list` / `update`)、节点树、文档与文件 |
|
||||
| 开发者文档 | `devdoc` | 2 | `article` `error` | 搜索钉钉开放平台文档、排查开放平台调用错误 |
|
||||
| AI 搜问 | `aisearch` | 3 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
|
||||
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
|
||||
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
|
||||
|
||||
> 12 个产品,86 个命令。运行 `dws --help` 查看完整列表,或 `dws <service> --help` 查看子命令。
|
||||
> **18 个产品,331 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
|
||||
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
|
||||
|
||||
<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>
|
||||
|
||||
@@ -410,6 +552,7 @@ 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 层
|
||||
- [更新日志](./CHANGELOG.md) — 版本历史与迁移说明
|
||||
|
||||
@@ -52,6 +52,8 @@ __KEG_ONLY_LINE__
|
||||
Pathname.new(File.join(Dir.home, ".amp/skills/dws")),
|
||||
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
@@ -7,6 +7,7 @@ const os = require("os");
|
||||
const path = require("path");
|
||||
const childProcess = require("child_process");
|
||||
|
||||
// Canonical list: keep scripts/install.sh, scripts/install.ps1, scripts/install-skills.sh in sync.
|
||||
const AGENT_DIRS = [
|
||||
".agents/skills",
|
||||
".claude/skills",
|
||||
@@ -20,6 +21,8 @@ const AGENT_DIRS = [
|
||||
".amp/skills",
|
||||
".kiro/skills",
|
||||
".trae/skills",
|
||||
".openclaw/skills",
|
||||
".hermes/skills",
|
||||
];
|
||||
|
||||
const PLATFORM_MAP = {
|
||||
@@ -133,11 +136,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}`);
|
||||
@@ -153,8 +181,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();
|
||||
|
||||
@@ -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, 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. |
|
||||
+50
-5
@@ -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 / 自动补全
|
||||
|
||||
@@ -14,11 +14,38 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15 // indirect
|
||||
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/bubbletea v1.3.6 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/huh v1.0.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // 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/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/muesli/termenv v0.16.0 // 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 (
|
||||
|
||||
@@ -1,8 +1,42 @@
|
||||
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/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/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/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/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=
|
||||
@@ -15,13 +49,29 @@ 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/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 +81,16 @@ 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/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=
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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] + "..."
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// resolveAccessTokenFromDir loads OAuth then legacy token from configDir, applying
|
||||
// the same host compatibility hooks as MCP. It mirrors the former body of
|
||||
// getCachedRuntimeToken (excluding process-level cache and timing).
|
||||
func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) {
|
||||
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
provider := authpkg.NewOAuthProvider(configDir, disc)
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
token, tokenErr := provider.GetAccessToken(ctx)
|
||||
if tokenErr == nil && strings.TrimSpace(token) != "" {
|
||||
return strings.TrimSpace(token), nil
|
||||
}
|
||||
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
|
||||
return "", tokenErr
|
||||
}
|
||||
manager := authpkg.NewManager(configDir, nil)
|
||||
configureLegacyAuthManagerCompatibility(manager)
|
||||
if leg, _, err := manager.GetToken(); err == nil && strings.TrimSpace(leg) != "" {
|
||||
return strings.TrimSpace(leg), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ResolveAuxiliaryAccessToken resolves a bearer token for HTTP clients that should
|
||||
// align with MCP tool calls. Non-empty explicitToken wins. When configDir matches
|
||||
// the active edition config directory, the same process-cached path as MCP is used.
|
||||
// Otherwise tokens are loaded from configDir with host compatibility hooks applied.
|
||||
func ResolveAuxiliaryAccessToken(ctx context.Context, configDir, explicitToken string) (string, error) {
|
||||
if t := strings.TrimSpace(explicitToken); t != "" {
|
||||
return t, nil
|
||||
}
|
||||
if strings.TrimSpace(configDir) == "" {
|
||||
return "", fmt.Errorf("config directory is empty")
|
||||
}
|
||||
if filepath.Clean(configDir) == filepath.Clean(defaultConfigDir()) {
|
||||
if tok := resolveRuntimeAuthToken(ctx, ""); tok != "" {
|
||||
return tok, nil
|
||||
}
|
||||
return "", noCredentialsError()
|
||||
}
|
||||
tok, err := resolveAccessTokenFromDir(ctx, configDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if tok != "" {
|
||||
return tok, nil
|
||||
}
|
||||
return "", noCredentialsError()
|
||||
}
|
||||
|
||||
func noCredentialsError() error {
|
||||
if edition.Get().IsEmbedded {
|
||||
return fmt.Errorf("认证信息已失效,请重新认证")
|
||||
}
|
||||
return fmt.Errorf("no credentials found, run: dws auth login")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveAuxiliaryAccessToken_explicitToken(t *testing.T) {
|
||||
tok, err := ResolveAuxiliaryAccessToken(context.Background(), "/any/dir", " bearer-xyz ")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tok != "bearer-xyz" {
|
||||
t.Fatalf("got %q, want bearer-xyz", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAuxiliaryAccessToken_emptyConfigDir(t *testing.T) {
|
||||
_, err := ResolveAuxiliaryAccessToken(context.Background(), " ", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty config directory")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -14,17 +14,22 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -55,6 +60,8 @@ func buildAuthCommand() *cobra.Command {
|
||||
cmd.AddCommand(
|
||||
newAuthLogoutCommand(),
|
||||
newAuthStatusCommand(),
|
||||
newAuthExportCommand(),
|
||||
newAuthImportCommand(),
|
||||
newAuthExchangeCommand(),
|
||||
newAuthResetCommand(),
|
||||
)
|
||||
@@ -68,16 +75,21 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
Long: `登录钉钉并获取认证凭证。
|
||||
|
||||
支持的登录方式:
|
||||
- OAuth 设备流 (默认): 通过钉钉扫码授权登录
|
||||
- 直接提供 Token: 通过 --token 参数传入已有 token
|
||||
- OAuth Loopback 流 (默认): 本机自动起 127.0.0.1 监听接收回调,浏览器授权后自动完成
|
||||
- OAuth 设备流 (--device): 显示 user_code + 短 URL,适合 SSH 远程 / 容器 / 无头环境
|
||||
- 直接提供 Token (--token): 跳过授权,使用已有 token
|
||||
|
||||
不支持的登录方式:
|
||||
- 邮箱/密码登录
|
||||
- 手机号/验证码登录
|
||||
- 应用凭证 (AppKey/AppSecret) 直接登录
|
||||
|
||||
注意: SSH 远程或无头环境(无本地浏览器可访问远端的 127.0.0.1)请使用 --device,
|
||||
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
|
||||
|
||||
示例:
|
||||
dws auth login # 扫码登录
|
||||
dws auth login # 本机扫码登录 (loopback 流)
|
||||
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
|
||||
dws auth login --force # 强制重新登录 (忽略缓存 token)
|
||||
dws auth login --token xxx # 使用指定 token`,
|
||||
DisableAutoGenTag: true,
|
||||
@@ -121,6 +133,7 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
@@ -197,15 +210,21 @@ func newAuthLogoutCommand() *cobra.Command {
|
||||
if err := authpkg.DeleteTokenData(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
||||
}
|
||||
// Clean up associated client secret from keychain
|
||||
// Clean up associated client secret and app token from keychain
|
||||
if storedClientID != "" {
|
||||
_ = authpkg.DeleteClientSecret(storedClientID)
|
||||
_ = authpkg.DeleteAppTokenData(storedClientID)
|
||||
}
|
||||
// Also try cleaning app token using appKey from app config
|
||||
if appKey, _ := authpkg.ResolveAppCredentials(configDir); appKey != "" && appKey != storedClientID {
|
||||
_ = authpkg.DeleteAppTokenData(appKey)
|
||||
}
|
||||
// Clean up app credentials (app.json + keychain secret)
|
||||
_ = authpkg.DeleteAppConfig(configDir)
|
||||
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
|
||||
_ = os.Remove(filepath.Join(configDir, "token"))
|
||||
_ = os.Remove(filepath.Join(configDir, "token.json"))
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintln(w, "[OK] 已清除所有认证信息")
|
||||
@@ -265,6 +284,13 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
} else {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "已登录 ✅")
|
||||
}
|
||||
if tokenData != nil {
|
||||
if tokenData.IsRefreshTokenValid() {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "Refresh Token:", "有效 ✅")
|
||||
} else {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "Refresh Token:", "缺失或已过期 ⚠️")
|
||||
}
|
||||
}
|
||||
if updatedAt := authStatusUpdatedAt(tokenData); updatedAt != "" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "有效期:", updatedAt)
|
||||
}
|
||||
@@ -279,6 +305,138 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func newAuthExportCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "导出可迁移认证包",
|
||||
Long: `导出包含 refresh token 与解密材料的认证包,便于在另一台 Linux 沙箱中导入。
|
||||
|
||||
包内包含 ~/.local/share/dws-cli 加密 keychain 与 ~/.dws 必要配置,不含 token 明文。
|
||||
|
||||
示例:
|
||||
dws auth export -o dws-auth.tar.gz
|
||||
dws auth export --base64 > dws-auth.b64`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
output, err := cmd.Flags().GetString("output")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --output")
|
||||
}
|
||||
asBase64, err := cmd.Flags().GetBool("base64")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --base64")
|
||||
}
|
||||
output = strings.TrimSpace(output)
|
||||
if !asBase64 && output == "" {
|
||||
return apperrors.NewValidation("--output is required unless --base64 is used")
|
||||
}
|
||||
if !authpkg.PortableExportSupported() {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"macOS 默认将 DEK 存在系统 Keychain,导出的包无法在其它机器解密;请设置 %s=1 后重新登录再导出",
|
||||
keychain.DisableKeychainEnv,
|
||||
))
|
||||
}
|
||||
if !authpkg.PortableAuthSourceReady() {
|
||||
return apperrors.NewValidation("尚未登录,请先运行 dws auth login")
|
||||
}
|
||||
|
||||
var bundle bytes.Buffer
|
||||
if err := authpkg.ExportPortableAuthBundle(defaultConfigDir(), &bundle); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to export auth bundle: %v", err))
|
||||
}
|
||||
|
||||
if asBase64 {
|
||||
payload := []byte(base64.StdEncoding.EncodeToString(bundle.Bytes()) + "\n")
|
||||
if output == "" {
|
||||
_, err := cmd.OutOrStdout().Write(payload)
|
||||
return err
|
||||
}
|
||||
if err := helpers.AtomicWrite(output, payload, config.FilePerm); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to write auth bundle: %v", err))
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "[OK] 已导出认证包: %s\n", output)
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "认证包含敏感凭据,用完请删除: rm -P %s\n", output)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := helpers.AtomicWrite(output, bundle.Bytes(), config.FilePerm); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to write auth bundle: %v", err))
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "[OK] 已导出认证包: %s\n", output)
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "认证包含敏感凭据,用完请删除: rm -P %s\n", output)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringP("output", "o", "", "认证包输出路径")
|
||||
cmd.Flags().Bool("base64", false, "将认证包编码为 base64,便于复制粘贴")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuthImportCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "import",
|
||||
Short: "导入可迁移认证包",
|
||||
Long: `从 dws auth export 生成的 tar.gz 或 base64 文件恢复认证。
|
||||
|
||||
导入后请运行 dws auth status 确认 refresh token 仍有效。
|
||||
|
||||
示例:
|
||||
dws auth import -i dws-auth.tar.gz
|
||||
dws auth import -i dws-auth.b64 --base64`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
input, err := cmd.Flags().GetString("input")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --input")
|
||||
}
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return apperrors.NewValidation("--input is required")
|
||||
}
|
||||
asBase64, err := cmd.Flags().GetBool("base64")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --base64")
|
||||
}
|
||||
force, err := cmd.Flags().GetBool("force")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --force")
|
||||
}
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
if !force && authpkg.PortableAuthTargetPopulated(configDir) {
|
||||
return apperrors.NewValidation("检测到已有登录态,请使用 --force 确认覆盖")
|
||||
}
|
||||
|
||||
payload, err := os.ReadFile(input)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to read auth bundle: %v", err))
|
||||
}
|
||||
if asBase64 {
|
||||
payload, err = base64.StdEncoding.DecodeString(strings.TrimSpace(string(payload)))
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("invalid base64 auth bundle: %v", err))
|
||||
}
|
||||
}
|
||||
report, err := authpkg.ImportPortableAuthBundle(configDir, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to import auth bundle: %v", err))
|
||||
}
|
||||
if report.OSMismatch {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "警告: 认证包来自 %s,当前系统为 %s,请确认解密材料兼容\n", report.BundleOS, runtime.GOOS)
|
||||
}
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "[OK] 已导入认证包")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "请运行 dws auth status 验证登录状态")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringP("input", "i", "", "认证包输入路径")
|
||||
cmd.Flags().Bool("base64", false, "输入为 base64 编码的认证包")
|
||||
cmd.Flags().Bool("force", false, "覆盖已有登录态")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuthExchangeCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "exchange",
|
||||
@@ -308,6 +466,7 @@ func newAuthExchangeCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return apperrors.NewAuth(fmt.Sprintf("failed to exchange authorization code: %v", err))
|
||||
}
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
@@ -347,6 +506,7 @@ func newAuthResetCommand() *cobra.Command {
|
||||
}
|
||||
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
|
||||
_ = os.Remove(filepath.Join(configDir, "token"))
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintln(w, "[OK] 认证信息已重置")
|
||||
|
||||
@@ -17,16 +17,121 @@ import (
|
||||
"bytes"
|
||||
"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"
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
@@ -44,7 +149,7 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
CorpID: "dingcorp",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
t.Skipf("SaveTokenData() unavailable in this environment: %v", err)
|
||||
}
|
||||
|
||||
originalTransport := http.DefaultTransport
|
||||
|
||||
@@ -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,59 @@
|
||||
// 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 "sync"
|
||||
|
||||
// PluginAuth holds authentication credentials for a plugin-owned
|
||||
// streamable-http MCP server. Each server is keyed by its canonical
|
||||
// product ID (CLI.ID) so that different servers can use independent
|
||||
// tokens without interfering with each other or with the default
|
||||
// DingTalk OAuth token.
|
||||
type PluginAuth struct {
|
||||
// Token is the Bearer token extracted from the plugin's
|
||||
// "Authorization" header (e.g. a third-party API key).
|
||||
Token string
|
||||
|
||||
// ExtraHeaders contains any additional custom HTTP headers
|
||||
// declared by the plugin (excluding Authorization).
|
||||
ExtraHeaders map[string]string
|
||||
|
||||
// TrustedDomains lists the hostnames that the token is allowed
|
||||
// to be sent to. Typically derived from the server endpoint.
|
||||
TrustedDomains []string
|
||||
}
|
||||
|
||||
var (
|
||||
pluginAuthMu sync.RWMutex
|
||||
pluginAuthRegistry = make(map[string]*PluginAuth)
|
||||
)
|
||||
|
||||
// RegisterPluginAuth stores authentication credentials for a plugin
|
||||
// server keyed by its canonical product ID. The runner looks up these
|
||||
// credentials at execution time to inject the correct Bearer token
|
||||
// instead of the default DingTalk OAuth token.
|
||||
func RegisterPluginAuth(productID string, auth *PluginAuth) {
|
||||
pluginAuthMu.Lock()
|
||||
defer pluginAuthMu.Unlock()
|
||||
pluginAuthRegistry[productID] = auth
|
||||
}
|
||||
|
||||
// LookupPluginAuth returns the authentication credentials registered
|
||||
// for the given product ID, or nil if none exists.
|
||||
func LookupPluginAuth(productID string) (*PluginAuth, bool) {
|
||||
pluginAuthMu.RLock()
|
||||
defer pluginAuthMu.RUnlock()
|
||||
auth, ok := pluginAuthRegistry[productID]
|
||||
return auth, ok
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
func TestPluginAuthRegistry(t *testing.T) {
|
||||
// Clean up after test
|
||||
defer func() {
|
||||
pluginAuthMu.Lock()
|
||||
delete(pluginAuthRegistry, "test-product")
|
||||
pluginAuthMu.Unlock()
|
||||
}()
|
||||
|
||||
// Initially not found
|
||||
if _, ok := LookupPluginAuth("test-product"); ok {
|
||||
t.Error("expected LookupPluginAuth to return false for unregistered product")
|
||||
}
|
||||
|
||||
// Register auth credentials
|
||||
auth := &PluginAuth{
|
||||
Token: "sk-test-token-12345",
|
||||
ExtraHeaders: map[string]string{"X-Custom": "value"},
|
||||
TrustedDomains: []string{"api.example.com", "*.example.com"},
|
||||
}
|
||||
RegisterPluginAuth("test-product", auth)
|
||||
|
||||
// Now should be found
|
||||
got, ok := LookupPluginAuth("test-product")
|
||||
if !ok {
|
||||
t.Fatal("expected LookupPluginAuth to return true after registration")
|
||||
}
|
||||
if got != auth {
|
||||
t.Error("LookupPluginAuth returned different auth instance")
|
||||
}
|
||||
if got.Token != "sk-test-token-12345" {
|
||||
t.Errorf("Token = %q, want sk-test-token-12345", got.Token)
|
||||
}
|
||||
if got.ExtraHeaders["X-Custom"] != "value" {
|
||||
t.Errorf("ExtraHeaders[X-Custom] = %q, want value", got.ExtraHeaders["X-Custom"])
|
||||
}
|
||||
if len(got.TrustedDomains) != 2 {
|
||||
t.Errorf("TrustedDomains len = %d, want 2", len(got.TrustedDomains))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginAuthRegistryIsolation(t *testing.T) {
|
||||
// Clean up after test
|
||||
defer func() {
|
||||
pluginAuthMu.Lock()
|
||||
delete(pluginAuthRegistry, "product-a")
|
||||
delete(pluginAuthRegistry, "product-b")
|
||||
pluginAuthMu.Unlock()
|
||||
}()
|
||||
|
||||
authA := &PluginAuth{Token: "token-a"}
|
||||
authB := &PluginAuth{Token: "token-b"}
|
||||
|
||||
RegisterPluginAuth("product-a", authA)
|
||||
RegisterPluginAuth("product-b", authB)
|
||||
|
||||
gotA, okA := LookupPluginAuth("product-a")
|
||||
gotB, okB := LookupPluginAuth("product-b")
|
||||
|
||||
if !okA || !okB {
|
||||
t.Fatal("expected both products to be registered")
|
||||
}
|
||||
if gotA.Token != "token-a" {
|
||||
t.Errorf("product-a Token = %q, want token-a", gotA.Token)
|
||||
}
|
||||
if gotB.Token != "token-b" {
|
||||
t.Errorf("product-b Token = %q, want token-b", gotB.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveToolCLIName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"web_search", "web-search"},
|
||||
{"maps.search_poi", "search-poi"},
|
||||
{"maps.geo", "geo"},
|
||||
{"simple", "simple"},
|
||||
{"a.b.deep_nested_name", "deep-nested-name"},
|
||||
{"already-kebab", "already-kebab"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := deriveToolCLIName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("deriveToolCLIName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterPluginAuthFromHeaders(t *testing.T) {
|
||||
// Clean up after test
|
||||
defer func() {
|
||||
pluginAuthMu.Lock()
|
||||
delete(pluginAuthRegistry, "test-srv")
|
||||
pluginAuthMu.Unlock()
|
||||
}()
|
||||
|
||||
srv := market.ServerDescriptor{
|
||||
Key: "test-srv",
|
||||
Endpoint: "https://api.example.com/mcp/v1",
|
||||
CLI: market.CLIOverlay{ID: "test-srv", Command: "test-srv"},
|
||||
AuthHeaders: map[string]string{
|
||||
"Authorization": "Bearer sk-my-secret-key",
|
||||
"X-Custom": "custom-value",
|
||||
},
|
||||
}
|
||||
|
||||
registerPluginAuthFromHeaders(srv)
|
||||
|
||||
auth, ok := LookupPluginAuth("test-srv")
|
||||
if !ok {
|
||||
t.Fatal("expected auth to be registered after registerPluginAuthFromHeaders")
|
||||
}
|
||||
if auth.Token != "sk-my-secret-key" {
|
||||
t.Errorf("Token = %q, want sk-my-secret-key", auth.Token)
|
||||
}
|
||||
if auth.ExtraHeaders["X-Custom"] != "custom-value" {
|
||||
t.Errorf("ExtraHeaders[X-Custom] = %q, want custom-value", auth.ExtraHeaders["X-Custom"])
|
||||
}
|
||||
if len(auth.TrustedDomains) != 2 {
|
||||
t.Fatalf("TrustedDomains len = %d, want 2", len(auth.TrustedDomains))
|
||||
}
|
||||
if auth.TrustedDomains[0] != "api.example.com" {
|
||||
t.Errorf("TrustedDomains[0] = %q, want api.example.com", auth.TrustedDomains[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterPluginAuthFromHeadersNoAuth(t *testing.T) {
|
||||
srv := market.ServerDescriptor{
|
||||
Key: "no-auth-srv",
|
||||
Endpoint: "https://api.example.com/mcp/v1",
|
||||
CLI: market.CLIOverlay{ID: "no-auth-srv"},
|
||||
AuthHeaders: map[string]string{
|
||||
"X-Custom": "custom-value",
|
||||
},
|
||||
}
|
||||
|
||||
registerPluginAuthFromHeaders(srv)
|
||||
|
||||
// Should not register because there's no Authorization header
|
||||
if _, ok := LookupPluginAuth("no-auth-srv"); ok {
|
||||
t.Error("expected no auth registration when Authorization header is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPluginAuthClient(t *testing.T) {
|
||||
base := transport.NewClient(nil)
|
||||
|
||||
srv := market.ServerDescriptor{
|
||||
Endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1/mcp",
|
||||
AuthHeaders: map[string]string{
|
||||
"Authorization": "Bearer sk-test-api-key",
|
||||
"X-Extra": "extra-value",
|
||||
},
|
||||
}
|
||||
|
||||
client := buildPluginAuthClient(base, srv)
|
||||
|
||||
// Should return a different client instance
|
||||
if client == base {
|
||||
t.Error("expected buildPluginAuthClient to return a new client, not the base")
|
||||
}
|
||||
|
||||
// Verify trusted domains
|
||||
if len(client.TrustedDomains) != 2 {
|
||||
t.Fatalf("TrustedDomains len = %d, want 2", len(client.TrustedDomains))
|
||||
}
|
||||
if client.TrustedDomains[0] != "dashscope.aliyuncs.com" {
|
||||
t.Errorf("TrustedDomains[0] = %q, want dashscope.aliyuncs.com", client.TrustedDomains[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPluginAuthClientNoAuth(t *testing.T) {
|
||||
base := transport.NewClient(nil)
|
||||
|
||||
srv := market.ServerDescriptor{
|
||||
Endpoint: "https://api.example.com/mcp/v1",
|
||||
AuthHeaders: map[string]string{
|
||||
"X-Custom": "custom-value",
|
||||
},
|
||||
}
|
||||
|
||||
client := buildPluginAuthClient(base, srv)
|
||||
|
||||
// Should return the base client when no Authorization header
|
||||
if client != base {
|
||||
t.Error("expected buildPluginAuthClient to return base client when no Authorization header")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,20 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CONFIG_DIR",
|
||||
Category: configmeta.CategoryCore,
|
||||
Description: "覆盖默认配置目录 (~/.dws)",
|
||||
DefaultValue: "~/.dws",
|
||||
Example: "/opt/dws/config",
|
||||
})
|
||||
}
|
||||
|
||||
// Build-time variables injected via ldflags when available.
|
||||
var (
|
||||
buildTime = "unknown"
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// 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"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newConfigCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "配置管理",
|
||||
Long: "管理 DWS CLI 的配置项。查看所有支持的环境变量及其当前值。",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newConfigListCommand())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newConfigListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "列出所有可用配置项",
|
||||
Long: "显示 DWS CLI 支持的全部环境变量配置项,包括名称、分类、描述和默认值。",
|
||||
RunE: runConfigList,
|
||||
}
|
||||
cmd.Flags().String("category", "", "按分类过滤 (core|auth|network|security|runtime|debug|external)")
|
||||
cmd.Flags().Bool("show-values", false, "显示配置项的当前实际值 (敏感信息会脱敏)")
|
||||
cmd.Flags().Bool("show-hidden", false, "包含隐藏的内部调试配置项")
|
||||
cmd.Flags().Bool("json", false, "以 JSON 格式输出")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConfigList(cmd *cobra.Command, _ []string) error {
|
||||
category, _ := cmd.Flags().GetString("category")
|
||||
showValues, _ := cmd.Flags().GetBool("show-values")
|
||||
showHidden, _ := cmd.Flags().GetBool("show-hidden")
|
||||
jsonOut, _ := cmd.Flags().GetBool("json")
|
||||
|
||||
var items []configmeta.ConfigItem
|
||||
if category != "" {
|
||||
items = configmeta.ByCategory(configmeta.Category(category))
|
||||
} else {
|
||||
items = configmeta.All()
|
||||
}
|
||||
|
||||
if !showHidden {
|
||||
items = filterVisible(items)
|
||||
}
|
||||
|
||||
if jsonOut {
|
||||
return writeConfigJSON(cmd, items, showValues)
|
||||
}
|
||||
return writeConfigTable(cmd, items, showValues)
|
||||
}
|
||||
|
||||
func filterVisible(items []configmeta.ConfigItem) []configmeta.ConfigItem {
|
||||
out := make([]configmeta.ConfigItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
if !item.Hidden {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeConfigJSON(cmd *cobra.Command, items []configmeta.ConfigItem, showValues bool) error {
|
||||
type jsonItem struct {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
DefaultValue string `json:"default_value,omitempty"`
|
||||
Example string `json:"example,omitempty"`
|
||||
Sensitive bool `json:"sensitive,omitempty"`
|
||||
CurrentValue string `json:"current_value,omitempty"`
|
||||
IsSet bool `json:"is_set"`
|
||||
}
|
||||
|
||||
result := make([]jsonItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
ji := jsonItem{
|
||||
Name: item.Name,
|
||||
Category: string(item.Category),
|
||||
Description: item.Description,
|
||||
DefaultValue: item.DefaultValue,
|
||||
Example: item.Example,
|
||||
Sensitive: item.Sensitive,
|
||||
}
|
||||
val, ok := configmeta.Resolve(item.Name)
|
||||
ji.IsSet = ok
|
||||
if showValues && ok {
|
||||
ji.CurrentValue = val
|
||||
}
|
||||
result = append(result, ji)
|
||||
}
|
||||
|
||||
return output.WriteJSON(cmd.OutOrStdout(), map[string]any{
|
||||
"kind": "config_list",
|
||||
"count": len(result),
|
||||
"configs": result,
|
||||
})
|
||||
}
|
||||
|
||||
func writeConfigTable(cmd *cobra.Command, items []configmeta.ConfigItem, showValues bool) error {
|
||||
w := cmd.OutOrStdout()
|
||||
|
||||
if len(items) == 0 {
|
||||
_, _ = fmt.Fprintln(w, "没有找到匹配的配置项。")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
|
||||
if showValues {
|
||||
_, _ = fmt.Fprintln(tw, "分类\t配置项\t描述\t默认值\t当前值")
|
||||
_, _ = fmt.Fprintln(tw, "────\t──────\t────\t──────\t──────")
|
||||
} else {
|
||||
_, _ = fmt.Fprintln(tw, "分类\t配置项\t描述\t默认值")
|
||||
_, _ = fmt.Fprintln(tw, "────\t──────\t────\t──────")
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
def := item.DefaultValue
|
||||
if def == "" {
|
||||
def = "(空)"
|
||||
}
|
||||
if showValues {
|
||||
val, ok := configmeta.Resolve(item.Name)
|
||||
display := "(未设置)"
|
||||
if ok {
|
||||
display = val
|
||||
}
|
||||
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
|
||||
item.Category, item.Name, item.Description, def, display)
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
|
||||
item.Category, item.Name, item.Description, def)
|
||||
}
|
||||
}
|
||||
|
||||
_ = tw.Flush()
|
||||
_, _ = fmt.Fprintf(w, "\n共 %d 个配置项。使用 --show-values 查看当前值,--show-hidden 显示隐藏项。\n", len(items))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// 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"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
)
|
||||
|
||||
func seedTestConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
configmeta.Reset()
|
||||
t.Cleanup(configmeta.Reset)
|
||||
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CONFIG_DIR", Category: configmeta.CategoryCore,
|
||||
Description: "覆盖默认配置目录", DefaultValue: "~/.dws",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CLIENT_SECRET", Category: configmeta.CategoryAuth,
|
||||
Description: "OAuth AppSecret", Sensitive: true,
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CATALOG_FIXTURE", Category: configmeta.CategoryDebug,
|
||||
Description: "目录 Fixture 路径", Hidden: true,
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigListTable(t *testing.T) {
|
||||
seedTestConfig(t)
|
||||
|
||||
cmd := newConfigListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "DWS_CONFIG_DIR") {
|
||||
t.Error("expected DWS_CONFIG_DIR in output")
|
||||
}
|
||||
if !strings.Contains(out, "DWS_CLIENT_SECRET") {
|
||||
t.Error("expected DWS_CLIENT_SECRET in output")
|
||||
}
|
||||
// Hidden items should be excluded by default
|
||||
if strings.Contains(out, "DWS_CATALOG_FIXTURE") {
|
||||
t.Error("expected DWS_CATALOG_FIXTURE to be hidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListShowHidden(t *testing.T) {
|
||||
seedTestConfig(t)
|
||||
|
||||
cmd := newConfigListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetArgs([]string{"--show-hidden"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "DWS_CATALOG_FIXTURE") {
|
||||
t.Error("expected DWS_CATALOG_FIXTURE with --show-hidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListCategory(t *testing.T) {
|
||||
seedTestConfig(t)
|
||||
|
||||
cmd := newConfigListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetArgs([]string{"--category", "auth"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "DWS_CLIENT_SECRET") {
|
||||
t.Error("expected DWS_CLIENT_SECRET for auth category")
|
||||
}
|
||||
if strings.Contains(out, "DWS_CONFIG_DIR") {
|
||||
t.Error("DWS_CONFIG_DIR should not appear for auth category")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListJSON(t *testing.T) {
|
||||
seedTestConfig(t)
|
||||
|
||||
cmd := newConfigListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetArgs([]string{"--json", "--show-hidden"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v", err)
|
||||
}
|
||||
if result["kind"] != "config_list" {
|
||||
t.Errorf("expected kind=config_list, got %v", result["kind"])
|
||||
}
|
||||
count, ok := result["count"].(float64)
|
||||
if !ok || count != 3 {
|
||||
t.Errorf("expected count=3, got %v", result["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListShowValues(t *testing.T) {
|
||||
seedTestConfig(t)
|
||||
|
||||
t.Setenv("DWS_CONFIG_DIR", "/custom/dir")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "supersecret123")
|
||||
|
||||
cmd := newConfigListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetArgs([]string{"--show-values"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "/custom/dir") {
|
||||
t.Error("expected actual value for DWS_CONFIG_DIR")
|
||||
}
|
||||
if strings.Contains(out, "supersecret123") {
|
||||
t.Error("sensitive value should be masked")
|
||||
}
|
||||
if !strings.Contains(out, "当前值") {
|
||||
t.Error("expected '当前值' column header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListEmpty(t *testing.T) {
|
||||
configmeta.Reset()
|
||||
defer configmeta.Reset()
|
||||
|
||||
cmd := newConfigListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "没有找到") {
|
||||
t.Error("expected empty message")
|
||||
}
|
||||
}
|
||||
+219
-12
@@ -14,10 +14,13 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
@@ -38,6 +41,57 @@ var legacyDirectRuntimeAliases = map[string]string{
|
||||
"dingtalk-ai-sincere-hire": "ai-sincere-hire",
|
||||
}
|
||||
|
||||
const (
|
||||
defaultPATProductID = "pat"
|
||||
defaultPATDisplayName = "行为授权"
|
||||
defaultPATServerID = "abc3c880fb90f04b52d1426aaf093766e5fc9ec38411688cbb74df42a584d374"
|
||||
)
|
||||
|
||||
func defaultPATServerDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Key: defaultPATProductID,
|
||||
DisplayName: defaultPATDisplayName,
|
||||
Endpoint: defaultPATMCPEndpoint(),
|
||||
CLI: market.CLIOverlay{
|
||||
ID: defaultPATProductID,
|
||||
Command: defaultPATProductID,
|
||||
Prefixes: []string{defaultPATProductID},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func defaultPATMCPEndpoint() string {
|
||||
return defaultPATGatewayBaseURL() + "/server/" + defaultPATServerID
|
||||
}
|
||||
|
||||
func defaultPATGatewayBaseURL() string {
|
||||
raw := strings.TrimSpace(authpkg.GetMCPBaseURL())
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return strings.TrimRight(raw, "/")
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
switch {
|
||||
case host == "mcp.dingtalk.com":
|
||||
host = "mcp-gw.dingtalk.com"
|
||||
case strings.HasPrefix(host, "pre-mcp."):
|
||||
host = strings.Replace(host, "pre-mcp.", "pre-mcp-gw.", 1)
|
||||
case strings.HasPrefix(host, "mcp."):
|
||||
host = strings.Replace(host, "mcp.", "mcp-gw.", 1)
|
||||
}
|
||||
|
||||
if port := parsed.Port(); port != "" {
|
||||
parsed.Host = net.JoinHostPort(host, port)
|
||||
} else {
|
||||
parsed.Host = host
|
||||
}
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return strings.TrimRight(parsed.String(), "/")
|
||||
}
|
||||
|
||||
// SetDynamicServers injects server data discovered from servers.json.
|
||||
// All product endpoints are resolved dynamically from this data.
|
||||
func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
@@ -48,6 +102,7 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
products := make(map[string]bool)
|
||||
aliases := make(map[string]string)
|
||||
toolEndpoints := make(map[string]string)
|
||||
registerDynamicServer(defaultPATServerDescriptor(), endpoints, products, aliases, toolEndpoints)
|
||||
for _, server := range servers {
|
||||
if server.CLI.Skip {
|
||||
continue
|
||||
@@ -80,11 +135,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 +157,47 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
dynamicToolEndpoints = toolEndpoints
|
||||
}
|
||||
|
||||
func registerDynamicServer(server market.ServerDescriptor, endpoints map[string]string, products map[string]bool, aliases map[string]string, toolEndpoints map[string]string) {
|
||||
if server.CLI.Skip {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(server.CLI.ID)
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
if id != "" && endpoint != "" {
|
||||
endpoints[id] = endpoint
|
||||
products[id] = true
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
endpoints[cmd] = endpoint
|
||||
products[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias != "" && endpoint != "" {
|
||||
endpoints[alias] = endpoint
|
||||
products[alias] = true
|
||||
// Build alias -> CLI.ID mapping.
|
||||
aliases[alias] = id
|
||||
}
|
||||
}
|
||||
// Build tool -> endpoint mapping from CLI tools and overrides.
|
||||
if endpoint != "" {
|
||||
for _, tool := range server.CLI.Tools {
|
||||
toolName := strings.TrimSpace(tool.Name)
|
||||
if toolName != "" {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName != "" {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldUseDirectRuntime(invocation executor.Invocation) bool {
|
||||
if strings.TrimSpace(os.Getenv(cli.CatalogFixtureEnv)) != "" {
|
||||
return false
|
||||
@@ -106,6 +210,25 @@ 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()
|
||||
te := dynamicToolEndpoints
|
||||
dynamicMu.RUnlock()
|
||||
if te == nil {
|
||||
return "", false
|
||||
}
|
||||
endpoint, ok := te[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)
|
||||
@@ -123,14 +246,11 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
te := dynamicToolEndpoints
|
||||
dynamicMu.RUnlock()
|
||||
|
||||
// Priority 1: tool-level endpoint (resolves multi-endpoint products).
|
||||
if tool := strings.TrimSpace(toolName); tool != "" && te != nil {
|
||||
if endpoint, ok := te[tool]; ok {
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: product-level endpoint.
|
||||
// Priority 1: product-level endpoint.
|
||||
// When the caller already knows the productID (e.g. "drive"), the product
|
||||
// endpoint is authoritative. This prevents cross-product tool name
|
||||
// collisions (e.g. both "drive" and "doc" register "create_folder") from
|
||||
// routing the request to the wrong MCP server. See issue #219.
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
@@ -141,6 +261,24 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: tool-level endpoint (fallback for unknown productID).
|
||||
// This path is used when the caller does not know the productID but has a
|
||||
// tool name, e.g. in helper invocations or plugin routes where only the
|
||||
// tool name is available.
|
||||
if tool := strings.TrimSpace(toolName); tool != "" && te != nil {
|
||||
if endpoint, ok := te[tool]; ok {
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: built-in PAT fallback for cold-start paths that run before
|
||||
// discovery/plugin registration has populated the dynamic registry.
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if candidate == defaultPATProductID {
|
||||
return defaultPATMCPEndpoint(), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -150,13 +288,82 @@ func DirectRuntimeProductIDs() map[string]bool {
|
||||
dynamicMu.RLock()
|
||||
dp := dynamicProducts
|
||||
dynamicMu.RUnlock()
|
||||
ids := make(map[string]bool, len(dp))
|
||||
ids := make(map[string]bool, len(dp)+1)
|
||||
ids[defaultPATProductID] = true
|
||||
for key := range dp {
|
||||
ids[key] = true
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// AppendDynamicServer adds a single server descriptor to the existing
|
||||
// dynamic server registry without replacing the current entries. This
|
||||
// is used by the plugin loader to inject plugin servers alongside
|
||||
// Market-discovered servers.
|
||||
func AppendDynamicServer(server market.ServerDescriptor) {
|
||||
dynamicMu.Lock()
|
||||
defer dynamicMu.Unlock()
|
||||
|
||||
if dynamicEndpoints == nil {
|
||||
dynamicEndpoints = make(map[string]string)
|
||||
}
|
||||
if dynamicProducts == nil {
|
||||
dynamicProducts = make(map[string]bool)
|
||||
}
|
||||
if dynamicAliases == nil {
|
||||
dynamicAliases = make(map[string]string)
|
||||
}
|
||||
if dynamicToolEndpoints == nil {
|
||||
dynamicToolEndpoints = make(map[string]string)
|
||||
}
|
||||
|
||||
if server.CLI.Skip {
|
||||
return
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(server.CLI.ID)
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
if id != "" && endpoint != "" {
|
||||
dynamicEndpoints[id] = endpoint
|
||||
dynamicProducts[id] = true
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
if _, exists := dynamicEndpoints[cmd]; !exists {
|
||||
dynamicEndpoints[cmd] = endpoint
|
||||
}
|
||||
dynamicProducts[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias != "" && endpoint != "" {
|
||||
dynamicEndpoints[alias] = endpoint
|
||||
dynamicProducts[alias] = true
|
||||
dynamicAliases[alias] = id
|
||||
}
|
||||
}
|
||||
if endpoint != "" {
|
||||
for _, tool := range server.CLI.Tools {
|
||||
toolName := strings.TrimSpace(tool.Name)
|
||||
if toolName != "" {
|
||||
dynamicToolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName, override := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,6 +1,74 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
)
|
||||
|
||||
func TestDefaultPATServerDescriptorUsesBehaviorAuthorizationName(t *testing.T) {
|
||||
server := defaultPATServerDescriptor()
|
||||
if server.CLI.ID != "pat" {
|
||||
t.Fatalf("default PAT server id = %q, want pat", server.CLI.ID)
|
||||
}
|
||||
if server.DisplayName != "行为授权" {
|
||||
t.Fatalf("default PAT server display name = %q, want 行为授权", server.DisplayName)
|
||||
}
|
||||
if server.Endpoint != defaultPATMCPEndpoint() {
|
||||
t.Fatalf("default PAT server endpoint = %q, want %q", server.Endpoint, defaultPATMCPEndpoint())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectRuntimeProductIDsIncludesDefaultPAT(t *testing.T) {
|
||||
dynamicMu.Lock()
|
||||
previousProducts := dynamicProducts
|
||||
dynamicProducts = nil
|
||||
dynamicMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
dynamicMu.Lock()
|
||||
dynamicProducts = previousProducts
|
||||
dynamicMu.Unlock()
|
||||
})
|
||||
|
||||
ids := DirectRuntimeProductIDs()
|
||||
if !ids["pat"] {
|
||||
t.Fatalf("DirectRuntimeProductIDs() missing default pat product: %#v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DefaultPATFallbackWhenRegistryMissing(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
assertEndpoint(t, "pat", "", defaultPATMCPEndpoint())
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DefaultPATFallbackUsesConfiguredMCPBaseURL(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "mcp_url"), []byte("http://127.0.0.1:54321/base"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", tmpDir)
|
||||
|
||||
assertEndpoint(t, "pat", "", "http://127.0.0.1:54321/base/server/"+defaultPATServerID)
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_PATDiscoveryOverrideWinsOverBuiltInFallback(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
customEndpoint := "https://example.com/server/custom-pat"
|
||||
SetDynamicServers([]market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: customEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "pat",
|
||||
Command: "pat",
|
||||
},
|
||||
},
|
||||
})
|
||||
assertEndpoint(t, "pat", "", customEndpoint)
|
||||
}
|
||||
|
||||
func TestNormalizeDirectRuntimeProductIDPreservesLegacyHiddenVendorRouting(t *testing.T) {
|
||||
dynamicMu.Lock()
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
// 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"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
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/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"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"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// checkStatus represents the outcome of a single doctor check.
|
||||
type checkStatus string
|
||||
|
||||
const (
|
||||
statusPass checkStatus = "pass"
|
||||
statusWarn checkStatus = "warn"
|
||||
statusFail checkStatus = "fail"
|
||||
)
|
||||
|
||||
// checkResult holds the outcome of a single doctor check.
|
||||
type checkResult struct {
|
||||
Name string `json:"name"`
|
||||
Status checkStatus `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
Detail any `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
func newDoctorCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "doctor",
|
||||
Short: "环境健康检查",
|
||||
Long: "一键检查登录态、网络连通性、缓存状态和版本更新,快速定位常见问题。",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: runDoctor,
|
||||
}
|
||||
cmd.Flags().Bool("json", false, "以 JSON 格式输出")
|
||||
cmd.Flags().Int("timeout", 10, "网络检查超时时间 (秒)")
|
||||
cmd.Flags().Bool("perf", false, "额外展示最近一次性能报告")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runDoctor(cmd *cobra.Command, _ []string) error {
|
||||
jsonOut, _ := cmd.Flags().GetBool("json")
|
||||
timeout, _ := cmd.Flags().GetInt("timeout")
|
||||
if timeout <= 0 {
|
||||
timeout = 10
|
||||
}
|
||||
networkTimeout := time.Duration(timeout) * time.Second
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
checks := make([]checkResult, 0, 4)
|
||||
|
||||
authResult := doctorCheckAuth(cmd.Context(), w, jsonOut)
|
||||
checks = append(checks, authResult)
|
||||
|
||||
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
|
||||
checks = append(checks, networkResult)
|
||||
|
||||
cacheResult := doctorCheckCache(w, jsonOut)
|
||||
checks = append(checks, cacheResult)
|
||||
|
||||
versionResult := doctorCheckVersion(w, jsonOut, networkTimeout)
|
||||
checks = append(checks, versionResult)
|
||||
|
||||
showPerf, _ := cmd.Flags().GetBool("perf")
|
||||
if showPerf {
|
||||
perfResult := doctorCheckPerf(w, jsonOut)
|
||||
checks = append(checks, perfResult)
|
||||
}
|
||||
|
||||
pass, warn, fail := countResults(checks)
|
||||
|
||||
if jsonOut {
|
||||
result := map[string]any{
|
||||
"kind": "doctor",
|
||||
"checks": checks,
|
||||
"summary": map[string]int{
|
||||
"pass": pass,
|
||||
"warn": warn,
|
||||
"fail": fail,
|
||||
},
|
||||
}
|
||||
if showPerf {
|
||||
if report, err := LoadLatestReport(); err == nil {
|
||||
result["perf_report"] = report
|
||||
}
|
||||
}
|
||||
return output.WriteJSON(w, result)
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "\n诊断完成: %d 项通过, %d 项警告, %d 项失败\n", pass, warn, fail)
|
||||
if fail > 0 {
|
||||
return fmt.Errorf("诊断发现 %d 项失败", fail)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Auth check ──────────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查登录状态... ")
|
||||
}
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
|
||||
data, err := provider.Status()
|
||||
if err != nil || data == nil {
|
||||
r := checkResult{Name: "auth", Status: statusFail, Message: "未登录"}
|
||||
if !edition.Get().IsEmbedded {
|
||||
r.Hint = "运行 dws auth login 进行登录"
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
if data.IsAccessTokenValid() || data.IsRefreshTokenValid() {
|
||||
if !data.IsAccessTokenValid() {
|
||||
refreshCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
_, refreshErr := provider.GetAccessToken(refreshCtx)
|
||||
cancel()
|
||||
if refreshErr != nil {
|
||||
r := checkResult{
|
||||
Name: "auth",
|
||||
Status: statusWarn,
|
||||
Message: "Refresh Token 有效, 但自动刷新 Access Token 失败",
|
||||
Hint: "运行 dws auth login 重新登录",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
r := checkResult{
|
||||
Name: "auth",
|
||||
Status: statusPass,
|
||||
Message: "已登录",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
r := checkResult{Name: "auth", Status: statusFail, Message: "登录已过期"}
|
||||
if !edition.Get().IsEmbedded {
|
||||
r.Hint = "运行 dws auth login 重新登录"
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Network check ───────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查网络连通性... ")
|
||||
}
|
||||
|
||||
baseURL := config.GetMCPBaseURL()
|
||||
httpClient := &http.Client{Timeout: timeout}
|
||||
client := market.NewClient(baseURL, httpClient)
|
||||
|
||||
start := time.Now()
|
||||
reqCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
_, err := client.FetchServers(reqCtx, 1)
|
||||
latency := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
r := checkResult{
|
||||
Name: "network",
|
||||
Status: statusFail,
|
||||
Message: fmt.Sprintf("%s 不可达: %v", baseURL, err),
|
||||
Hint: "请检查网络连接或代理设置",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
r := checkResult{
|
||||
Name: "network",
|
||||
Status: statusPass,
|
||||
Message: fmt.Sprintf("%s 可达 (延迟 %dms)", baseURL, latency.Milliseconds()),
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Cache check ─────────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查缓存状态... ")
|
||||
}
|
||||
|
||||
store := cacheStoreFromEnv()
|
||||
files, _, err := cacheDirectoryStats(store.Root)
|
||||
if err != nil {
|
||||
r := checkResult{
|
||||
Name: "cache",
|
||||
Status: statusFail,
|
||||
Message: fmt.Sprintf("缓存目录不可读: %v", err),
|
||||
Hint: "运行 dws cache clean 清理后重试",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
entries, _ := store.ListToolsCacheEntries(config.DefaultPartition)
|
||||
|
||||
if files == 0 && len(entries) == 0 {
|
||||
r := checkResult{
|
||||
Name: "cache",
|
||||
Status: statusWarn,
|
||||
Message: "缓存为空 (首次使用)",
|
||||
Hint: "运行任意 dws 命令后将自动建立缓存",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
staleCount := 0
|
||||
for _, e := range entries {
|
||||
if e.Freshness == cache.FreshnessStale {
|
||||
staleCount++
|
||||
}
|
||||
}
|
||||
|
||||
if staleCount > 0 {
|
||||
r := checkResult{
|
||||
Name: "cache",
|
||||
Status: statusWarn,
|
||||
Message: fmt.Sprintf("%d 个文件, %d 个工具缓存, %d 个已过期", files, len(entries), staleCount),
|
||||
Hint: "运行 dws cache refresh 刷新缓存",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("%d 个文件, %d 个工具缓存", files, len(entries))
|
||||
if len(entries) > 0 {
|
||||
msg += ", 全部新鲜"
|
||||
}
|
||||
r := checkResult{
|
||||
Name: "cache",
|
||||
Status: statusPass,
|
||||
Message: msg,
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Version check ───────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查版本更新... ")
|
||||
}
|
||||
|
||||
currentVer := version
|
||||
|
||||
client := upgrade.NewClient()
|
||||
latest, err := client.FetchLatestRelease()
|
||||
if err != nil {
|
||||
r := checkResult{
|
||||
Name: "version",
|
||||
Status: statusFail,
|
||||
Message: fmt.Sprintf("无法获取最新版本: %v", err),
|
||||
Hint: "请检查网络连接",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
if upgrade.NeedsUpgrade(currentVer, latest.Version) {
|
||||
r := checkResult{
|
||||
Name: "version",
|
||||
Status: statusWarn,
|
||||
Message: fmt.Sprintf("有新版本 (当前 %s, 最新 v%s)", ensureV(currentVer), latest.Version),
|
||||
Hint: "运行 dws upgrade 升级到最新版本",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
r := checkResult{
|
||||
Name: "version",
|
||||
Status: statusPass,
|
||||
Message: fmt.Sprintf("已是最新版本 %s", ensureV(currentVer)),
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Output helpers ──────────────────────────────────────────────────────
|
||||
|
||||
func printCheckResult(w io.Writer, r checkResult) {
|
||||
icon := statusIcon(r.Status)
|
||||
fmt.Fprintf(w, "%s %s\n", icon, r.Message)
|
||||
if r.Hint != "" {
|
||||
fmt.Fprintf(w, " %s\n", r.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func statusIcon(s checkStatus) string {
|
||||
switch s {
|
||||
case statusPass:
|
||||
return "✅"
|
||||
case statusWarn:
|
||||
return "⚠️"
|
||||
case statusFail:
|
||||
return "❌"
|
||||
default:
|
||||
return "?"
|
||||
}
|
||||
}
|
||||
|
||||
func countResults(checks []checkResult) (pass, warn, fail int) {
|
||||
for _, c := range checks {
|
||||
switch c.Status {
|
||||
case statusPass:
|
||||
pass++
|
||||
case statusWarn:
|
||||
warn++
|
||||
case statusFail:
|
||||
fail++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ── Perf report check ──────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckPerf(w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查性能报告... ")
|
||||
}
|
||||
|
||||
report, err := LoadLatestReport()
|
||||
if err != nil {
|
||||
r := checkResult{
|
||||
Name: "perf",
|
||||
Status: statusWarn,
|
||||
Message: "未找到性能报告",
|
||||
Hint: "设置 DWS_PERF_REPORT=auto 后运行任意命令生成报告",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
r := checkResult{
|
||||
Name: "perf",
|
||||
Status: statusPass,
|
||||
Message: fmt.Sprintf("报告可用 (%s, %s)", report.Command, report.Timestamp.Local().Format("2006-01-02 15:04")),
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
printPerfReportSummary(w, report)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func printPerfReportSummary(w io.Writer, report *PerfReport) {
|
||||
fmt.Fprintf(w, "\n最近一次性能报告 (%s, %s):\n",
|
||||
report.Command, report.Timestamp.Local().Format("2006-01-02 15:04"))
|
||||
|
||||
for _, p := range report.Phases {
|
||||
marker := ""
|
||||
if p.Name == report.Slowest {
|
||||
marker = " ← 最慢"
|
||||
}
|
||||
fmt.Fprintf(w, " %-25s %dms%s\n", p.Name, p.DurationMs, marker)
|
||||
}
|
||||
fmt.Fprintf(w, " %-25s ─────────\n", "─────────────────────────")
|
||||
fmt.Fprintf(w, " %-25s %dms (框架开销 %dms)\n", "总耗时", report.TotalMs, report.OverheadMs)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// 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"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCountResults(t *testing.T) {
|
||||
checks := []checkResult{
|
||||
{Status: statusPass},
|
||||
{Status: statusPass},
|
||||
{Status: statusWarn},
|
||||
{Status: statusFail},
|
||||
}
|
||||
pass, warn, fail := countResults(checks)
|
||||
if pass != 2 || warn != 1 || fail != 1 {
|
||||
t.Errorf("expected (2,1,1), got (%d,%d,%d)", pass, warn, fail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountResultsAllPass(t *testing.T) {
|
||||
checks := []checkResult{
|
||||
{Status: statusPass},
|
||||
{Status: statusPass},
|
||||
}
|
||||
pass, warn, fail := countResults(checks)
|
||||
if pass != 2 || warn != 0 || fail != 0 {
|
||||
t.Errorf("expected (2,0,0), got (%d,%d,%d)", pass, warn, fail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusIcon(t *testing.T) {
|
||||
tests := []struct {
|
||||
status checkStatus
|
||||
want string
|
||||
}{
|
||||
{statusPass, "✅"},
|
||||
{statusWarn, "⚠️"},
|
||||
{statusFail, "❌"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := statusIcon(tc.status)
|
||||
if got != tc.want {
|
||||
t.Errorf("statusIcon(%q) = %q, want %q", tc.status, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintCheckResult(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
r := checkResult{
|
||||
Name: "test",
|
||||
Status: statusFail,
|
||||
Message: "something broke",
|
||||
Hint: "try fixing it",
|
||||
}
|
||||
printCheckResult(&buf, r)
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "❌") {
|
||||
t.Error("expected fail icon")
|
||||
}
|
||||
if !strings.Contains(out, "something broke") {
|
||||
t.Error("expected message")
|
||||
}
|
||||
if !strings.Contains(out, "try fixing it") {
|
||||
t.Error("expected hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintCheckResultNoHint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
r := checkResult{
|
||||
Name: "test",
|
||||
Status: statusPass,
|
||||
Message: "all good",
|
||||
}
|
||||
printCheckResult(&buf, r)
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "✅") {
|
||||
t.Error("expected pass icon")
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
if len(lines) != 1 {
|
||||
t.Errorf("expected 1 line (no hint), got %d", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckCacheEmpty(t *testing.T) {
|
||||
t.Setenv("DWS_CACHE_DIR", t.TempDir())
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckCache(&buf, false)
|
||||
|
||||
if r.Status != statusWarn {
|
||||
t.Errorf("expected warn for empty cache, got %s", r.Status)
|
||||
}
|
||||
if !strings.Contains(r.Message, "缓存为空") {
|
||||
t.Errorf("expected empty cache message, got %q", r.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
|
||||
t.Setenv("DWS_CACHE_DIR", t.TempDir())
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckCache(&buf, true)
|
||||
|
||||
if r.Status != statusWarn {
|
||||
t.Errorf("expected warn for empty cache, got %s", r.Status)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Error("expected no output in JSON mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCommandStructure(t *testing.T) {
|
||||
cmd := newDoctorCommand()
|
||||
if cmd.Use != "doctor" {
|
||||
t.Errorf("Use = %q, want doctor", cmd.Use)
|
||||
}
|
||||
|
||||
jsonFlag := cmd.Flags().Lookup("json")
|
||||
if jsonFlag == nil {
|
||||
t.Error("expected --json flag")
|
||||
}
|
||||
timeoutFlag := cmd.Flags().Lookup("timeout")
|
||||
if timeoutFlag == nil {
|
||||
t.Error("expected --timeout flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckResultJSONMarshal(t *testing.T) {
|
||||
r := checkResult{
|
||||
Name: "auth",
|
||||
Status: statusPass,
|
||||
Message: "已登录",
|
||||
}
|
||||
data, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed["name"] != "auth" {
|
||||
t.Errorf("expected name=auth, got %v", parsed["name"])
|
||||
}
|
||||
if parsed["status"] != "pass" {
|
||||
t.Errorf("expected status=pass, got %v", parsed["status"])
|
||||
}
|
||||
if _, hasHint := parsed["hint"]; hasHint {
|
||||
t.Error("empty hint should be omitted")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -35,12 +36,12 @@ type GlobalFlags struct {
|
||||
}
|
||||
|
||||
func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) {
|
||||
cmd.PersistentFlags().StringVar(&flags.ClientID, "client-id", "", "Override OAuth client ID (DingTalk AppKey)")
|
||||
cmd.PersistentFlags().StringVar(&flags.ClientSecret, "client-secret", "", "Override OAuth client secret (DingTalk AppSecret)")
|
||||
cmd.PersistentFlags().StringVar(&flags.ClientID, "client-id", "", i18n.T("覆盖 OAuth 客户端 ID (钉钉 AppKey)"))
|
||||
cmd.PersistentFlags().StringVar(&flags.ClientSecret, "client-secret", "", i18n.T("覆盖 OAuth 客户端密钥 (钉钉 AppSecret)"))
|
||||
cmd.PersistentFlags().BoolVar(&flags.Debug, "debug", false, "显示调试日志")
|
||||
cmd.PersistentFlags().BoolVar(&flags.DryRun, "dry-run", false, "预览操作内容,不实际执行")
|
||||
cmd.PersistentFlags().StringVar(&flags.Fields, "fields", "", "筛选输出字段 (逗号分隔, 如: name,id,status)")
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -56,7 +56,6 @@ func TestRootCommandDoesNotInjectPatchedHelpCommands(t *testing.T) {
|
||||
|
||||
root := NewRootCommand()
|
||||
for _, path := range []string{
|
||||
"doc upload",
|
||||
"chat message list-topic-replies",
|
||||
"minutes list all",
|
||||
} {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// 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
|
||||
|
||||
// MCPIdentityHeaders returns the same header map used for MCP HTTP requests
|
||||
// (agent identity, env trace headers, edition MergeHeaders). Intended for
|
||||
// non-MCP transports such as the A2A gateway client.
|
||||
func MCPIdentityHeaders() map[string]string {
|
||||
return resolveIdentityHeaders()
|
||||
}
|
||||
+249
-53
@@ -30,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"
|
||||
@@ -48,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
|
||||
@@ -88,16 +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.
|
||||
func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
|
||||
totalStart := time.Now()
|
||||
defer func() {
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] loadDynamicCommands total: %v\n", time.Since(totalStart))
|
||||
}
|
||||
}()
|
||||
// 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
|
||||
@@ -108,18 +258,13 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
|
||||
// --- Cache-first server registry ---
|
||||
cacheLoadStart := time.Now()
|
||||
snapshot, freshness, cacheErr := store.LoadRegistry(partition)
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] cache load: %v (err=%v)\n", time.Since(cacheLoadStart), cacheErr)
|
||||
}
|
||||
RecordTiming(ctx, "registry_cache", time.Since(cacheLoadStart))
|
||||
|
||||
var servers []market.ServerDescriptor
|
||||
now := store.Now().UTC()
|
||||
usingCachedRegistry := useCache && cacheErr == nil && len(snapshot.Servers) > 0
|
||||
|
||||
if usingCachedRegistry {
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] using cached registry: servers=%d, freshness=%s\n", len(snapshot.Servers), freshness)
|
||||
}
|
||||
servers = snapshot.Servers
|
||||
// Only trigger async revalidation in production (no URL override).
|
||||
// Tests set discoveryBaseURLOverride and control cache expiry directly,
|
||||
@@ -129,34 +274,50 @@ 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 os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] fetching from market API: %s\n", baseURL)
|
||||
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)
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] market API fetch: %v (err=%v)\n", time.Since(fetchStart), fetchErr)
|
||||
}
|
||||
|
||||
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")
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] normalized servers: %d\n", len(servers))
|
||||
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 {
|
||||
@@ -164,30 +325,37 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
|
||||
if saveErr := store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}); saveErr != nil {
|
||||
slog.Debug("loadDynamicCommands: failed to save registry cache", "error", saveErr)
|
||||
}
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] cache save: %v\n", time.Since(saveStart))
|
||||
}
|
||||
RecordTiming(ctx, "cache_save", time.Since(saveStart))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] load details: %v\n", time.Since(detailStart))
|
||||
}
|
||||
RecordTiming(ctx, "tool_metadata", time.Since(detailStart))
|
||||
|
||||
buildStart := time.Now()
|
||||
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID)
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] build commands: %v (count=%d)\n", time.Since(buildStart), len(cmds))
|
||||
}
|
||||
RecordTiming(ctx, "build_commands", time.Since(buildStart))
|
||||
|
||||
return cmds
|
||||
}
|
||||
@@ -199,7 +367,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
|
||||
@@ -230,7 +398,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()
|
||||
@@ -357,17 +525,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)
|
||||
},
|
||||
@@ -375,6 +545,29 @@ 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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -382,9 +575,7 @@ 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
|
||||
@@ -425,3 +616,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).
|
||||
|
||||
@@ -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/bamboo":
|
||||
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/bamboo":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"metadata": map[string]any{"count": 2, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
const (
|
||||
// PatAuthRetryTimeout is the maximum time to wait for user authorization
|
||||
// when a PAT scope error is detected.
|
||||
PatAuthRetryTimeout = 10 * time.Minute
|
||||
|
||||
// PatAuthPollInterval is how often we poll to check if the user has
|
||||
// completed authorization.
|
||||
PatAuthPollInterval = 5 * time.Second
|
||||
|
||||
patScopeAuthRequiredCode = "PAT_SCOPE_AUTH_REQUIRED"
|
||||
)
|
||||
|
||||
var openBrowserFunc = tryOpenBrowser
|
||||
|
||||
// PatScopeError holds information about a missing PAT scope.
|
||||
type PatScopeError struct {
|
||||
OriginalError string
|
||||
Identity string
|
||||
ErrorType string
|
||||
Message string
|
||||
Hint string
|
||||
MissingScope string
|
||||
}
|
||||
|
||||
func (e *PatScopeError) Error() string {
|
||||
return e.OriginalError
|
||||
}
|
||||
|
||||
// patScopeRegex matches PAT-protocol scope error patterns from the API.
|
||||
// Only matches explicit scope-related keywords; generic "permission denied" or
|
||||
// "forbidden" are intentionally excluded to avoid false positives on business
|
||||
// authorization errors (e.g. mailbox access denied, 403 Forbidden).
|
||||
var patScopeRegex = regexp.MustCompile(`(?i)(missing_scope|insufficient_scope|scope.*required)`)
|
||||
|
||||
// scopeValueRegex extracts a scope identifier (e.g. "calendar:read",
|
||||
// "mail:user_mailbox.message:send") from an error message.
|
||||
// Supports multi-segment scopes with multiple colons (resource:sub:action).
|
||||
var scopeValueRegex = regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9_.]*(?::[a-zA-Z][a-zA-Z0-9_.]*)+)`)
|
||||
|
||||
// identityValueRegex extracts an identity label from an error message.
|
||||
var identityValueRegex = regexp.MustCompile(`(?i)identity["\s:]+([a-zA-Z_]+)`)
|
||||
|
||||
// isPatScopeError checks if an error looks like a PAT scope/permission error
|
||||
// that can be resolved by re-authorizing with additional scopes.
|
||||
func isPatScopeError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
|
||||
// Check for missing_scope pattern in error message or hint
|
||||
if patScopeRegex.MatchString(msg) {
|
||||
return true
|
||||
}
|
||||
|
||||
var typed *apperrors.Error
|
||||
if stderrors.As(err, &typed) {
|
||||
// Check message, reason, and hint for scope-related patterns
|
||||
fullText := strings.ToLower(typed.Message + " " + typed.Reason + " " + typed.Hint)
|
||||
if typed.Category == apperrors.CategoryAuth {
|
||||
if strings.Contains(fullText, "missing_scope") || strings.Contains(fullText, "insufficient_scope") ||
|
||||
(strings.Contains(fullText, "scope") && strings.Contains(fullText, "required")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Any category with scope/permission hints
|
||||
if strings.Contains(fullText, "missing_scope") || strings.Contains(fullText, "insufficient_scope") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// extractPatScopeError parses an error to extract PAT scope details.
|
||||
func extractPatScopeError(err error) *PatScopeError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
scope := ""
|
||||
|
||||
var typed *apperrors.Error
|
||||
if stderrors.As(err, &typed) {
|
||||
msg = typed.Message
|
||||
if typed.Reason != "" {
|
||||
msg += " (" + typed.Reason + ")"
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract scope value (e.g. "calendar:read") from error message.
|
||||
scopeMatch := scopeValueRegex.FindStringSubmatch(msg)
|
||||
if len(scopeMatch) > 1 {
|
||||
scope = scopeMatch[1]
|
||||
}
|
||||
|
||||
// Try to extract identity from error message.
|
||||
identity := "user"
|
||||
identityMatch := identityValueRegex.FindStringSubmatch(msg)
|
||||
if len(identityMatch) > 1 {
|
||||
identity = identityMatch[1]
|
||||
}
|
||||
|
||||
return &PatScopeError{
|
||||
OriginalError: err.Error(),
|
||||
Identity: identity,
|
||||
ErrorType: "missing_scope",
|
||||
Message: msg,
|
||||
Hint: fmt.Sprintf("run `dws auth login --scope %q` to authorize the missing scope", scope),
|
||||
MissingScope: scope,
|
||||
}
|
||||
}
|
||||
|
||||
// PrintPatAuthError prints a human-readable PAT authorization error.
|
||||
func PrintPatAuthError(w io.Writer, scopeErr *PatScopeError) {
|
||||
bold := color.New(color.Bold).SprintFunc()
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "{\n")
|
||||
fmt.Fprintf(w, " %s: %s,\n", bold("\"ok\""), "false")
|
||||
fmt.Fprintf(w, " %s: %q,\n", bold("\"identity\""), scopeErr.Identity)
|
||||
fmt.Fprintf(w, " %s: {\n", bold("\"error\""))
|
||||
fmt.Fprintf(w, " %s: %q,\n", bold("\"type\""), scopeErr.ErrorType)
|
||||
fmt.Fprintf(w, " %s: %q,\n", bold("\"message\""), scopeErr.Message)
|
||||
fmt.Fprintf(w, " %s: %q\n", bold("\"hint\""), scopeErr.Hint)
|
||||
fmt.Fprintf(w, " }\n")
|
||||
fmt.Fprintf(w, "}\n")
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// Print authorization instructions
|
||||
fmt.Fprintf(w, "%s %s\n", green("▶"), bold("需要额外授权"))
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " %s %s\n", dim("#"), dim("运行以下命令完成授权"))
|
||||
|
||||
if scopeErr.MissingScope != "" {
|
||||
fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan(fmt.Sprintf("dws auth login --scope %q", scopeErr.MissingScope)))
|
||||
} else {
|
||||
fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan("dws auth login"))
|
||||
}
|
||||
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " %s 在浏览器中打开授权链接,完成授权后重新执行命令\n", dim("ℹ"))
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// PrintPatAuthJSON prints a machine-readable PAT authorization error.
|
||||
func PrintPatAuthJSON(w io.Writer, scopeErr *PatScopeError) {
|
||||
fmt.Fprintln(w, buildPATScopeJSON(scopeErr, authpkg.HostOwnsPATFlow()))
|
||||
}
|
||||
|
||||
func wantsStructuredPATOutput(r *runtimeRunner) bool {
|
||||
if r == nil || r.globalFlags == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(r.globalFlags.Format), "json")
|
||||
}
|
||||
|
||||
func wantsStructuredPATOutputFromRunner(runner executor.Runner) bool {
|
||||
rr, ok := runner.(*runtimeRunner)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return wantsStructuredPATOutput(rr)
|
||||
}
|
||||
|
||||
func currentPATOpenBrowser(configDir string) bool {
|
||||
return pat.EffectiveOpenBrowser(configDir)
|
||||
}
|
||||
|
||||
func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw
|
||||
}
|
||||
|
||||
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, ok := data["uri"].(string); ok && strings.TrimSpace(rawURI) != "" {
|
||||
data["authorizationUrl"] = apperrors.PATAuthorizationURL(rawURI)
|
||||
}
|
||||
data["openBrowser"] = openBrowser
|
||||
|
||||
encoded, err := marshalSingleLineJSONNoHTMLEscape(payload)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
// WaitForPatAuthorization polls until the user completes authorization or timeout.
|
||||
// It returns true if authorization was completed, false if timed out or cancelled.
|
||||
func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool {
|
||||
bold := color.New(color.Bold).SprintFunc()
|
||||
yellow := color.New(color.FgYellow).SprintFunc()
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
red := color.New(color.FgRed).SprintFunc()
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
|
||||
timeout := PatAuthRetryTimeout
|
||||
deadline := time.Now().Add(timeout)
|
||||
pollTicker := time.NewTicker(PatAuthPollInterval)
|
||||
defer pollTicker.Stop()
|
||||
start := time.Now()
|
||||
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintf(output, "%s %s\n", yellow("⏳"), bold("等待用户授权..."))
|
||||
fmt.Fprintf(output, " %s 请在另一个终端完成 dws auth login 授权\n", dim("ℹ"))
|
||||
fmt.Fprintf(output, " %s 超时时间: %s\n", dim("⏱"), timeout)
|
||||
fmt.Fprintln(output)
|
||||
|
||||
pollCount := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
fmt.Fprintf(output, "%s 操作已取消\n", red("✗"))
|
||||
return false
|
||||
|
||||
case <-time.After(time.Until(deadline)):
|
||||
fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", red("✗"), timeout)
|
||||
fmt.Fprintf(output, " %s 请重新执行命令\n", dim("ℹ"))
|
||||
return false
|
||||
|
||||
case <-pollTicker.C:
|
||||
pollCount++
|
||||
elapsed := time.Since(start).Truncate(time.Second)
|
||||
remaining := time.Until(deadline).Truncate(time.Second)
|
||||
|
||||
// Check if token is now valid
|
||||
tokenData, err := authpkg.LoadTokenData(configDir)
|
||||
if err == nil && tokenData != nil {
|
||||
if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() {
|
||||
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
|
||||
green("✓"), bold("授权成功!"), elapsed, remaining)
|
||||
fmt.Fprintln(output)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Show polling status
|
||||
fmt.Fprintf(output, "\r%s [%d] 等待授权中... (%s 已用, %s 剩余) ",
|
||||
dim("⟳"), pollCount, elapsed, remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// retryWithPatAuthRetry wraps an invocation that failed with a PAT scope error.
|
||||
// It waits for the user to complete authorization and then retries the invocation.
|
||||
func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocation executor.Invocation, scopeErr *PatScopeError, configDir string, output io.Writer) (executor.Result, error) {
|
||||
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)
|
||||
|
||||
// Wait for user to complete authorization
|
||||
authorized := WaitForPatAuthorization(ctx, configDir, output)
|
||||
if !authorized {
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"等待用户授权超时",
|
||||
apperrors.WithReason("pat_auth_timeout"),
|
||||
apperrors.WithHint(fmt.Sprintf("授权超时 (%s),请重新执行命令", PatAuthRetryTimeout)),
|
||||
apperrors.WithActions("dws auth login"),
|
||||
)
|
||||
}
|
||||
|
||||
// Clear the token cache so the new token is loaded
|
||||
ResetRuntimeTokenCache()
|
||||
|
||||
// Retry the invocation
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintf(output, "%s %s\n", color.New(color.FgGreen).SprintFunc()("▶"),
|
||||
color.New(color.Bold).SprintFunc()("授权完成,正在重试..."))
|
||||
fmt.Fprintln(output)
|
||||
|
||||
return runner.Run(ctx, invocation)
|
||||
}
|
||||
|
||||
// ---- handlePatAuthCheck (runner.go entry point) -----------------------------
|
||||
|
||||
const (
|
||||
// patPollInterval is how often we poll the device flow status endpoint.
|
||||
patPollInterval = 2 * time.Second
|
||||
// patPollTimeout is the maximum time to wait for user authorization via device flow.
|
||||
patPollTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
// patRetryingKey is a context key to prevent recursive PAT auth checks.
|
||||
// After APPROVED, the retry should not trigger another PAT flow.
|
||||
type patRetryingKeyType struct{}
|
||||
|
||||
var patRetryingKey = patRetryingKeyType{}
|
||||
|
||||
// IsPatRetrying returns true if the current context is already in a PAT retry.
|
||||
func IsPatRetrying(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(patRetryingKey).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
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 while still
|
||||
// preserving the original data.uri 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)
|
||||
}
|
||||
|
||||
// handlePatAuthCheck is called by runner.executeInvocation when a PAT
|
||||
// authorization error is detected. It injects the server-assigned clientId
|
||||
// as x-robot-uid header, prints authorization details, opens the browser,
|
||||
// polls the device flow endpoint until the user authorizes, and retries the
|
||||
// original invocation on success.
|
||||
func handlePatAuthCheck(
|
||||
ctx context.Context,
|
||||
r *runtimeRunner,
|
||||
invocation executor.Invocation,
|
||||
patErr *apperrors.PATError,
|
||||
configDir string,
|
||||
output io.Writer,
|
||||
) (executor.Result, error) {
|
||||
// Parse authorization details from PATError.RawJSON.
|
||||
var patData struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
Desc string `json:"desc"`
|
||||
FlowID string `json:"flowId"`
|
||||
URI string `json:"uri"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(patErr.RawJSON), &patData); err != nil {
|
||||
return executor.Result{}, patErr
|
||||
}
|
||||
|
||||
slog.Debug("PAT auth check",
|
||||
"clientId", patData.Data.ClientID,
|
||||
"flowId", patData.Data.FlowID,
|
||||
"hasSecret", patData.Data.ClientSecret != "",
|
||||
)
|
||||
hostOwnedPAT := authpkg.HostOwnsPATFlow()
|
||||
openBrowser := currentPATOpenBrowser(configDir)
|
||||
slog.Debug("pat.host_owned_decision",
|
||||
"site", "handlePatAuthCheck",
|
||||
"hostOwned", hostOwnedPAT,
|
||||
"agentCodeEnvSet", os.Getenv(authpkg.AgentCodeEnv) != "",
|
||||
)
|
||||
|
||||
// Inject clientId/clientSecret from PAT response as runtime credentials
|
||||
// so that subsequent device flow auth uses the server-assigned app identity.
|
||||
var appCfg *authpkg.AppConfig
|
||||
if patData.Data.ClientID != "" {
|
||||
if patData.Data.ClientSecret != "" {
|
||||
// When both clientId and clientSecret are provided, use direct mode
|
||||
// (DingTalk API) rather than MCP proxy — the MCP proxy does not hold
|
||||
// the secret for this particular app.
|
||||
authpkg.SetClientID(patData.Data.ClientID)
|
||||
authpkg.SetClientSecret(patData.Data.ClientSecret)
|
||||
} else {
|
||||
// No clientSecret — rely on MCP proxy to manage the secret server-side.
|
||||
authpkg.SetClientIDFromMCP(patData.Data.ClientID)
|
||||
}
|
||||
|
||||
// Persist 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)}
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
authURL := apperrors.PATAuthorizationURL(patData.Data.URI)
|
||||
fmt.Fprintf(output, " %s 授权链接: %s\n", dim("🔗"), cyan(authURL))
|
||||
fmt.Fprintf(output, " PAT_AUTHORIZATION_URL=%s\n\n", authURL)
|
||||
if openBrowser {
|
||||
_ = openPATAuthorizationURI(authURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Poll the device flow status until user authorizes, rejects, or timeout.
|
||||
fmt.Fprintf(output, "%s %s\n", yellowFn("⏳"), bold("等待用户授权..."))
|
||||
fmt.Fprintf(output, " %s 请在浏览器中完成授权,超时时间: %s\n", dim("ℹ"), patPollTimeout)
|
||||
fmt.Fprintln(output)
|
||||
|
||||
pollCtx, cancel := context.WithTimeout(ctx, patPollTimeout)
|
||||
defer cancel()
|
||||
|
||||
status, authCode, err := pollPatDeviceFlow(pollCtx, patData.Data.FlowID, configDir, output)
|
||||
if err != nil {
|
||||
fmt.Fprintf(output, "%s 轮询授权状态失败: %v\n", redFn("✗"), err)
|
||||
return executor.Result{}, patErr
|
||||
}
|
||||
|
||||
switch status {
|
||||
case authpkg.StatusApproved:
|
||||
fmt.Fprintf(output, "%s %s\n", greenFn("✓"), bold("授权成功!"))
|
||||
fmt.Fprintln(output)
|
||||
|
||||
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)
|
||||
} else {
|
||||
if err := authpkg.SaveTokenData(configDir, tokenData); err != nil {
|
||||
slog.Warn("PAT retry: failed to save new token", "error", err)
|
||||
fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", yellowFn("⚠"), err)
|
||||
} else {
|
||||
slog.Debug("PAT retry: token refreshed and saved")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear token cache so the new credentials take effect.
|
||||
ResetRuntimeTokenCache()
|
||||
|
||||
// Workaround: brief delay to let server-side authorization state propagate
|
||||
// before retrying. Without this the retry may use stale credentials.
|
||||
slog.Debug("PAT retry: waiting for server-side state propagation", "delay", "1s")
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// Retry the original invocation with pat-retrying flag to prevent recursion.
|
||||
fmt.Fprintf(output, "%s %s\n", greenFn("▶"), bold("授权完成,正在重试..."))
|
||||
fmt.Fprintln(output)
|
||||
slog.Debug("PAT retry: identity env check",
|
||||
"DWS_CLIENT_ID", os.Getenv("DWS_CLIENT_ID"),
|
||||
)
|
||||
retryCtx := context.WithValue(ctx, patRetryingKey, true)
|
||||
return r.Run(retryCtx, invocation)
|
||||
|
||||
case authpkg.StatusRejected:
|
||||
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("用户已拒绝授权"))
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"用户已拒绝授权",
|
||||
apperrors.WithReason("pat_auth_rejected"),
|
||||
apperrors.WithHint("用户在浏览器中拒绝了授权请求,请重新执行命令。"),
|
||||
)
|
||||
|
||||
case authpkg.StatusExpired:
|
||||
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("授权超时"))
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"授权超时",
|
||||
apperrors.WithReason("pat_auth_expired"),
|
||||
apperrors.WithHint("授权链接已过期,请重新执行命令。"),
|
||||
)
|
||||
|
||||
case authpkg.StatusCancelled:
|
||||
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("操作已取消"))
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"操作已取消",
|
||||
apperrors.WithReason("pat_auth_cancelled"),
|
||||
apperrors.WithHint("用户取消了授权操作。"),
|
||||
)
|
||||
|
||||
default:
|
||||
fmt.Fprintf(output, "%s 未知授权状态: %s\n", redFn("✗"), status)
|
||||
return executor.Result{}, patErr
|
||||
}
|
||||
}
|
||||
|
||||
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 := json.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) {
|
||||
pollURL := fmt.Sprintf("%s%s?flowId=%s",
|
||||
authpkg.GetMCPBaseURL(), authpkg.DevicePollPath, url.QueryEscape(flowID))
|
||||
|
||||
// Load user access token for the poll request header.
|
||||
var accessToken string
|
||||
if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil {
|
||||
accessToken = tokenData.AccessToken
|
||||
}
|
||||
|
||||
// Use a client that does NOT follow redirects, so we can detect SSO 302.
|
||||
noRedirectClient := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(patPollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
pollCount := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if ctx.Err() == context.Canceled {
|
||||
return authpkg.StatusCancelled, "", nil
|
||||
}
|
||||
return authpkg.StatusExpired, "", nil
|
||||
case <-ticker.C:
|
||||
pollCount++
|
||||
fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", dim("⟳"), pollCount)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil)
|
||||
if err != nil {
|
||||
slog.Debug("PAT poll: failed to create request", "error", err)
|
||||
continue
|
||||
}
|
||||
if accessToken != "" {
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
}
|
||||
resp, err := noRedirectClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Debug("PAT poll: request failed", "error", err)
|
||||
continue // transient network error, keep polling
|
||||
}
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
// If we got a redirect (302/301), SSO gateway intercepted — skip JSON parse.
|
||||
if resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusMovedPermanently {
|
||||
continue
|
||||
}
|
||||
|
||||
var pollResp authpkg.DevicePollResponse
|
||||
if err := json.Unmarshal(bodyBytes, &pollResp); err != nil {
|
||||
slog.Debug("PAT poll: failed to parse response", "error", err, "body", string(bodyBytes))
|
||||
printPATPollDebugResponse(output, resp.StatusCode, bodyBytes)
|
||||
continue
|
||||
}
|
||||
|
||||
pollData := pollResp.EffectiveData()
|
||||
status := authpkg.ParseDeviceFlowStatus(pollData.Status, pollResp.Success)
|
||||
switch status {
|
||||
case authpkg.StatusApproved:
|
||||
fmt.Fprintln(output) // clear the polling line
|
||||
return status, pollData.AuthCode, nil
|
||||
case authpkg.StatusRejected, authpkg.StatusExpired:
|
||||
fmt.Fprintln(output) // clear the polling line
|
||||
return status, "", nil
|
||||
case authpkg.StatusPending:
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func browserOpenCommand(goos, rawURL string) *exec.Cmd {
|
||||
switch goos {
|
||||
case "darwin":
|
||||
return exec.Command("open", rawURL)
|
||||
case "linux":
|
||||
return exec.Command("xdg-open", rawURL)
|
||||
case "windows":
|
||||
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
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
// 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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newPluginCommand() *cobra.Command {
|
||||
pluginCmd := newPlaceholderParent("plugin", i18n.T("插件管理"))
|
||||
|
||||
pluginCmd.AddCommand(
|
||||
newPluginListCommand(),
|
||||
newPluginInstallCommand(),
|
||||
newPluginInfoCommand(),
|
||||
newPluginEnableCommand(),
|
||||
newPluginDisableCommand(),
|
||||
newPluginRemoveCommand(),
|
||||
newPluginValidateCommand(),
|
||||
newPluginCreateCommand(),
|
||||
newPluginDevCommand(),
|
||||
newPluginConfigCommand(),
|
||||
newPluginBuildCommand(),
|
||||
)
|
||||
|
||||
return pluginCmd
|
||||
}
|
||||
|
||||
func newPluginListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: i18n.T("列出已安装的插件"),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
plugins := loader.ListInstalled()
|
||||
|
||||
wantJSON, _ := cmd.Flags().GetBool("json")
|
||||
if wantJSON {
|
||||
return output.WriteJSON(cmd.OutOrStdout(), plugins)
|
||||
}
|
||||
|
||||
if len(plugins) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "No plugins installed.")
|
||||
return nil
|
||||
}
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintf(w, "%-35s %-12s %-10s %-10s %s\n",
|
||||
"NAME", "VERSION", "TYPE", "STATUS", "DESCRIPTION")
|
||||
fmt.Fprintln(w, strings.Repeat("-", 85))
|
||||
for _, p := range plugins {
|
||||
fmt.Fprintf(w, "%-35s %-12s %-10s %-10s %s\n",
|
||||
p.Name, p.Version, p.Type, statusStr(p.Enabled), p.Description)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().Bool("json", false, "Output in JSON format")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newPluginInstallCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "install",
|
||||
Short: i18n.T("安装插件"),
|
||||
Example: ` dws plugin install --dir ./conference
|
||||
dws plugin install --git https://github.com/DingTalk-Real-AI/conference.git`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dirPath, _ := cmd.Flags().GetString("dir")
|
||||
gitURL, _ := cmd.Flags().GetString("git")
|
||||
|
||||
if dirPath == "" && gitURL == "" {
|
||||
return apperrors.NewValidation("specify install source: --dir <path> or --git <url>")
|
||||
}
|
||||
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
|
||||
if gitURL != "" {
|
||||
p, err := loader.InstallFromGit(gitURL)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("install failed: %v", err))
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Installed %s (%s)\n", p.Manifest.Name, p.Manifest.Version)
|
||||
return nil
|
||||
}
|
||||
|
||||
p, err := loader.InstallFromDir(dirPath)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("install failed: %v", err))
|
||||
}
|
||||
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Installed %s (%s)\n", p.Manifest.Name, p.Manifest.Version)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("dir", "", "Install from a local directory")
|
||||
cmd.Flags().String("git", "", "Install from a Git repository")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newPluginInfoCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "info <name>",
|
||||
Short: i18n.T("查看插件详情"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
plugins := loader.ListInstalled()
|
||||
|
||||
for _, p := range plugins {
|
||||
if p.Name == name {
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintf(w, "Name: %s\n", p.Name)
|
||||
fmt.Fprintf(w, "Version: %s\n", p.Version)
|
||||
fmt.Fprintf(w, "Type: %s\n", p.Type)
|
||||
fmt.Fprintf(w, "Status: %s\n", statusStr(p.Enabled))
|
||||
fmt.Fprintf(w, "Path: %s\n", p.Path)
|
||||
if p.Description != "" {
|
||||
fmt.Fprintf(w, "Description: %s\n", p.Description)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return apperrors.NewValidation(fmt.Sprintf("plugin %q not found", name))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newPluginEnableCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "enable <name>",
|
||||
Short: i18n.T("启用插件"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
if err := loader.SetEnabled(args[0], true); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Plugin %s enabled.\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newPluginDisableCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "disable <name>",
|
||||
Short: i18n.T("禁用插件"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
if err := loader.SetEnabled(args[0], false); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Plugin %s disabled.\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newPluginRemoveCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove <name>",
|
||||
Short: i18n.T("卸载已安装的插件"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Stop stdio clients before removing to release file locks
|
||||
StopStdioClientsByPlugin(args[0])
|
||||
keepData, _ := cmd.Flags().GetBool("keep-data")
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
if err := loader.RemovePlugin(args[0], keepData); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Plugin %s removed.\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().Bool("keep-data", false, "Keep plugin data directory")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newPluginValidateCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "validate <dir>",
|
||||
Short: i18n.T("校验 plugin.json"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir := args[0]
|
||||
m, err := plugin.ParseManifest(dir + "/plugin.json")
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("parse failed: %v", err))
|
||||
}
|
||||
if err := m.Validate(RawVersion()); err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("validation failed: %v", err))
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Valid: %s (%s)\n", m.Name, m.Version)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newPluginCreateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <name>",
|
||||
Short: i18n.T("脚手架生成新插件目录"),
|
||||
Example: ` dws plugin create my-tool
|
||||
dws plugin create my-tool --description "My awesome tool"`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
desc, _ := cmd.Flags().GetString("description")
|
||||
pluginType := "user"
|
||||
|
||||
// Validate name format
|
||||
m := &plugin.Manifest{Name: name, Version: "0.1.0", Type: pluginType}
|
||||
if err := m.Validate(""); err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("invalid plugin name: %v", err))
|
||||
}
|
||||
|
||||
dir := filepath.Join(".", name)
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("directory %q already exists", dir))
|
||||
}
|
||||
|
||||
// Create directory structure
|
||||
dirs := []string{
|
||||
dir,
|
||||
filepath.Join(dir, "skills", name),
|
||||
filepath.Join(dir, "hooks"),
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to create directory: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Write plugin.json
|
||||
pluginJSON := fmt.Sprintf(`{
|
||||
"name": %q,
|
||||
"version": "0.1.0",
|
||||
"description": %q,
|
||||
"type": %q,
|
||||
"minCLIVersion": %q,
|
||||
"mcpServers": {
|
||||
%q: {
|
||||
"type": "stdio",
|
||||
"command": "${DWS_PLUGIN_ROOT}/bin/server",
|
||||
"args": []
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"command": "echo 'TODO: replace with your build command, e.g.: bun build --compile src/server.ts --outfile bin/server'",
|
||||
"output": "bin/server"
|
||||
},
|
||||
"skills": "./skills/",
|
||||
"hooks": "./hooks/hooks.json"
|
||||
}
|
||||
`, name, desc, pluginType, RawVersion(), name)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(pluginJSON), 0o644); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to write plugin.json: %v", err))
|
||||
}
|
||||
|
||||
// Write SKILL.md template
|
||||
skillMD := fmt.Sprintf(`---
|
||||
name: %s
|
||||
description: %s
|
||||
cli_version: ">=%s"
|
||||
---
|
||||
|
||||
# %s
|
||||
|
||||
## Intent Recognition
|
||||
|
||||
Use this skill when the user mentions:
|
||||
- TODO: add your intent keywords here
|
||||
|
||||
## Command Decision Tree
|
||||
|
||||
| User Intent | Command | Required Parameters |
|
||||
|-------------|---------|---------------------|
|
||||
| TODO | `+"`dws %s <sub-command>`"+` | `+"`--param`"+` |
|
||||
|
||||
## Parameter Rules
|
||||
|
||||
### TODO: parameter type
|
||||
- Format description
|
||||
- Conversion rules
|
||||
`, name, desc, RawVersion(), name, name)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, "skills", name, "SKILL.md"), []byte(skillMD), 0o644); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to write SKILL.md: %v", err))
|
||||
}
|
||||
|
||||
// Write hooks.json template
|
||||
hooksJSON := `{
|
||||
"hooks": []
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "hooks", "hooks.json"), []byte(hooksJSON), 0o644); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to write hooks.json: %v", err))
|
||||
}
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintf(w, "Created plugin scaffold at ./%s/\n", name)
|
||||
fmt.Fprintf(w, " %s/\n", name)
|
||||
fmt.Fprintf(w, " ├── plugin.json\n")
|
||||
fmt.Fprintf(w, " ├── skills/%s/SKILL.md\n", name)
|
||||
fmt.Fprintf(w, " └── hooks/hooks.json\n")
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "Next steps:\n")
|
||||
fmt.Fprintf(w, " 1. Edit plugin.json to configure your MCP servers\n")
|
||||
fmt.Fprintf(w, " 2. Edit skills/%s/SKILL.md to describe your commands\n", name)
|
||||
fmt.Fprintf(w, " 3. Run: dws plugin validate ./%s\n", name)
|
||||
fmt.Fprintf(w, " 4. Run: dws plugin dev ./%s\n", name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("description", "", "Plugin description")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newPluginDevCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "dev <dir>",
|
||||
Short: i18n.T("将本地目录注册为开发态插件"),
|
||||
Long: `Registers a plugin from a local source directory for development.
|
||||
The plugin is loaded directly from the source directory on next CLI invocation,
|
||||
without copying files to ~/.dws/plugins/. Use 'dws plugin dev --off <name>'
|
||||
to unregister.`,
|
||||
Example: ` dws plugin dev ./my-tool
|
||||
dws plugin dev --off my-tool`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
off, _ := cmd.Flags().GetBool("off")
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
|
||||
if off {
|
||||
// Unregister dev plugin
|
||||
name := args[0]
|
||||
if err := loader.UnregisterDevPlugin(name); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Dev plugin %q unregistered.\n", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Register dev plugin
|
||||
dir := args[0]
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("invalid path: %v", err))
|
||||
}
|
||||
|
||||
// Validate the plugin first
|
||||
m, err := plugin.ParseManifest(filepath.Join(absDir, "plugin.json"))
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("invalid plugin at %s: %v", dir, err))
|
||||
}
|
||||
if err := m.Validate(RawVersion()); err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("validation failed: %v", err))
|
||||
}
|
||||
|
||||
if err := loader.RegisterDevPlugin(m.Name, absDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to register: %v", err))
|
||||
}
|
||||
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Dev plugin %q registered from %s\n", m.Name, absDir)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "It will be loaded on next dws invocation.\n")
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "To unregister: dws plugin dev --off %s\n", m.Name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().Bool("off", false, "Unregister a dev plugin")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newPluginConfigCommand() *cobra.Command {
|
||||
configCmd := newPlaceholderParent("config", i18n.T("管理插件配置"))
|
||||
configCmd.AddCommand(
|
||||
newPluginConfigSetCommand(),
|
||||
newPluginConfigGetCommand(),
|
||||
newPluginConfigListCommand(),
|
||||
newPluginConfigUnsetCommand(),
|
||||
)
|
||||
return configCmd
|
||||
}
|
||||
|
||||
func newPluginConfigSetCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "set <plugin-name> <key> <value>",
|
||||
Short: i18n.T("设置插件配置项"),
|
||||
Long: `Persistently set a configuration value for a plugin.
|
||||
The value is stored in ~/.dws/settings.json and automatically injected
|
||||
as an environment variable when the plugin is loaded.
|
||||
|
||||
Environment variables set by the user (e.g. via export) take precedence
|
||||
over values stored in settings.json.`,
|
||||
Example: ` dws plugin config set demo-devtool DASHSCOPE_API_KEY sk-xxx
|
||||
dws plugin config set my-plugin API_ENDPOINT https://api.example.com`,
|
||||
Args: cobra.ExactArgs(3),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pluginName, key, value := args[0], args[1], args[2]
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
|
||||
// Validate that the plugin exists.
|
||||
plugins := loader.ListInstalled()
|
||||
found := false
|
||||
for _, p := range plugins {
|
||||
if p.Name == pluginName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return apperrors.NewValidation(fmt.Sprintf("plugin %q not found; use 'dws plugin list' to see installed plugins", pluginName))
|
||||
}
|
||||
|
||||
loader.SetPluginConfig(pluginName, key, value)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Config saved: %s.%s\n", pluginName, key)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newPluginConfigGetCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "get <plugin-name> <key>",
|
||||
Short: i18n.T("读取插件配置项"),
|
||||
Args: cobra.ExactArgs(2),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pluginName, key := args[0], args[1]
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
|
||||
val, ok := loader.GetPluginConfig(pluginName, key)
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("config key %q not set for plugin %q", key, pluginName))
|
||||
}
|
||||
|
||||
fmt.Fprintln(cmd.OutOrStdout(), val)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newPluginConfigListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list <plugin-name>",
|
||||
Short: i18n.T("列出插件所有配置项"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pluginName := args[0]
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
|
||||
wantJSON, _ := cmd.Flags().GetBool("json")
|
||||
configs := loader.ListPluginConfig(pluginName)
|
||||
|
||||
// Also load the plugin manifest to show declared userConfig keys.
|
||||
declaredKeys := loadDeclaredUserConfig(loader, pluginName)
|
||||
|
||||
if wantJSON {
|
||||
result := make(map[string]any)
|
||||
for k, v := range configs {
|
||||
sensitive := false
|
||||
if ci, ok := declaredKeys[k]; ok {
|
||||
sensitive = ci.Sensitive
|
||||
}
|
||||
if sensitive {
|
||||
result[k] = maskSensitiveValue(v)
|
||||
} else {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
// Include declared but unset keys.
|
||||
for k, ci := range declaredKeys {
|
||||
if _, set := configs[k]; !set {
|
||||
entry := map[string]any{
|
||||
"value": nil,
|
||||
"description": ci.Description,
|
||||
"required": ci.Default == "",
|
||||
}
|
||||
result[k] = entry
|
||||
}
|
||||
}
|
||||
return output.WriteJSON(cmd.OutOrStdout(), map[string]any{
|
||||
"kind": "plugin_config",
|
||||
"plugin": pluginName,
|
||||
"config": result,
|
||||
})
|
||||
}
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
if len(configs) == 0 && len(declaredKeys) == 0 {
|
||||
fmt.Fprintf(w, "No configuration for plugin %q.\n", pluginName)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "Configuration for %s:\n\n", pluginName)
|
||||
|
||||
// Show set values.
|
||||
for k, v := range configs {
|
||||
sensitive := false
|
||||
if ci, ok := declaredKeys[k]; ok {
|
||||
sensitive = ci.Sensitive
|
||||
}
|
||||
displayVal := v
|
||||
if sensitive {
|
||||
displayVal = maskSensitiveValue(v)
|
||||
}
|
||||
fmt.Fprintf(w, " %s = %s\n", k, displayVal)
|
||||
}
|
||||
|
||||
// Show declared but unset keys.
|
||||
for k, ci := range declaredKeys {
|
||||
if _, set := configs[k]; !set {
|
||||
desc := ""
|
||||
if ci.Description != "" {
|
||||
desc = " # " + ci.Description
|
||||
}
|
||||
fmt.Fprintf(w, " %s = (not set)%s\n", k, desc)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().Bool("json", false, "Output in JSON format")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newPluginConfigUnsetCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "unset <plugin-name> <key>",
|
||||
Short: i18n.T("删除插件配置项"),
|
||||
Args: cobra.ExactArgs(2),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pluginName, key := args[0], args[1]
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
|
||||
if !loader.UnsetPluginConfig(pluginName, key) {
|
||||
return apperrors.NewValidation(fmt.Sprintf("config key %q not set for plugin %q", key, pluginName))
|
||||
}
|
||||
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Config removed: %s.%s\n", pluginName, key)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// loadDeclaredUserConfig loads the userConfig section from a plugin's manifest.
|
||||
func loadDeclaredUserConfig(loader *plugin.Loader, pluginName string) map[string]plugin.ConfigItem {
|
||||
plugins := loader.ListInstalled()
|
||||
for _, p := range plugins {
|
||||
if p.Name == pluginName {
|
||||
m, err := plugin.ParseManifest(filepath.Join(p.Path, "plugin.json"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return m.UserConfig
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// maskSensitiveValue masks a sensitive value, showing only the first 4
|
||||
// and last 2 characters for values longer than 8 characters.
|
||||
func maskSensitiveValue(value string) string {
|
||||
if len(value) <= 8 {
|
||||
return strings.Repeat("*", len(value))
|
||||
}
|
||||
return value[:4] + strings.Repeat("*", len(value)-6) + value[len(value)-2:]
|
||||
}
|
||||
|
||||
func newPluginBuildCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "build <dir>",
|
||||
Short: i18n.T("将插件 stdio server 编译为原生二进制"),
|
||||
Long: `Runs the build command declared in plugin.json to compile the
|
||||
plugin's server into a single executable. This ensures plugin users
|
||||
don't need any language runtime (Node.js, Python, etc.) installed.
|
||||
|
||||
The build configuration is read from the "build" field in plugin.json:
|
||||
|
||||
{
|
||||
"build": {
|
||||
"command": "bun build --compile src/server.ts --outfile bin/server",
|
||||
"output": "bin/server"
|
||||
}
|
||||
}`,
|
||||
Example: ` dws plugin build ./my-plugin
|
||||
dws plugin build .`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir := args[0]
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("invalid path: %v", err))
|
||||
}
|
||||
|
||||
m, err := plugin.ParseManifest(filepath.Join(absDir, "plugin.json"))
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("invalid plugin at %s: %v", dir, err))
|
||||
}
|
||||
|
||||
if m.Build == nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"plugin %q has no \"build\" field in plugin.json.\n"+
|
||||
"Add a build config, e.g.:\n\n"+
|
||||
" \"build\": {\n"+
|
||||
" \"command\": \"bun build --compile src/server.js --outfile bin/server\",\n"+
|
||||
" \"output\": \"bin/server\"\n"+
|
||||
" }", m.Name))
|
||||
}
|
||||
|
||||
if err := plugin.BuildPlugin(absDir); err != nil {
|
||||
return apperrors.NewInternal(err.Error())
|
||||
}
|
||||
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Build succeeded: %s\n", m.Build.Output)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func statusStr(enabled bool) string {
|
||||
if enabled {
|
||||
return "enabled"
|
||||
}
|
||||
return "disabled"
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
// TestSharedCacheStoreConcurrentSaveTools verifies that a single *cache.Store
|
||||
// instance is safe for goroutines saving tool snapshots concurrently, as long
|
||||
// as each goroutine targets a distinct (partition, serverKey). This mirrors
|
||||
// the real plugin discovery path where each goroutine owns one plugin/server.
|
||||
//
|
||||
// Each call serializes to its own "<key>.json.tmp" file followed by a
|
||||
// rename(2) to the final path, so concurrent writers targeting distinct keys
|
||||
// never collide. The invariant asserted here: after N parallel writes, the
|
||||
// Store returns each written snapshot intact under LoadTools.
|
||||
func TestSharedCacheStoreConcurrentSaveTools(t *testing.T) {
|
||||
const (
|
||||
partition = "default/default"
|
||||
writers = 16
|
||||
)
|
||||
|
||||
store := cache.NewStore(t.TempDir())
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < writers; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
key := fmt.Sprintf("plugin:concurrent:%d", idx)
|
||||
if err := store.SaveTools(partition, key, cache.ToolsSnapshot{
|
||||
ServerKey: key,
|
||||
}); err != nil {
|
||||
t.Errorf("SaveTools(%s): %v", key, err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < writers; i++ {
|
||||
key := fmt.Sprintf("plugin:concurrent:%d", i)
|
||||
snapshot, _, err := store.LoadTools(partition, key)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTools(%s): %v", key, err)
|
||||
}
|
||||
if snapshot.ServerKey != key {
|
||||
t.Errorf("LoadTools(%s) returned ServerKey %q", key, snapshot.ServerKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendDynamicServerConcurrent exercises the dynamicMu mutex on the
|
||||
// write path by spraying distinct server descriptors in parallel. Afterwards
|
||||
// every injected product ID must be resolvable — a missing entry would
|
||||
// indicate a lost write through an un-synchronized map update.
|
||||
func TestAppendDynamicServerConcurrent(t *testing.T) {
|
||||
dynamicMu.Lock()
|
||||
prev := struct {
|
||||
endpoints map[string]string
|
||||
products map[string]bool
|
||||
aliases map[string]string
|
||||
toolEndpoints map[string]string
|
||||
}{dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints}
|
||||
dynamicEndpoints = nil
|
||||
dynamicProducts = nil
|
||||
dynamicAliases = nil
|
||||
dynamicToolEndpoints = nil
|
||||
dynamicMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
dynamicMu.Lock()
|
||||
dynamicEndpoints = prev.endpoints
|
||||
dynamicProducts = prev.products
|
||||
dynamicAliases = prev.aliases
|
||||
dynamicToolEndpoints = prev.toolEndpoints
|
||||
dynamicMu.Unlock()
|
||||
})
|
||||
|
||||
const n = 32
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
id := fmt.Sprintf("plugin-id-%d", idx)
|
||||
endpoint := fmt.Sprintf("https://example.test/%d", idx)
|
||||
AppendDynamicServer(market.ServerDescriptor{
|
||||
Endpoint: endpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: id,
|
||||
Command: id,
|
||||
},
|
||||
})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
id := fmt.Sprintf("plugin-id-%d", i)
|
||||
if endpoint, ok := directRuntimeEndpoint(id, ""); !ok || endpoint == "" {
|
||||
t.Errorf("directRuntimeEndpoint(%q) = (%q, %v), want non-empty", id, endpoint, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterStdioClientConcurrent verifies the stdioMu-protected registry
|
||||
// survives concurrent writers — every registered client must be looked up
|
||||
// afterwards. Uses nil client pointers since LookupStdioClient only compares
|
||||
// keys, not values.
|
||||
func TestRegisterStdioClientConcurrent(t *testing.T) {
|
||||
stdioMu.Lock()
|
||||
prev := stdioClients
|
||||
stdioClients = make(map[string]*transport.StdioClient)
|
||||
stdioMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
stdioMu.Lock()
|
||||
stdioClients = prev
|
||||
stdioMu.Unlock()
|
||||
})
|
||||
|
||||
const n = 32
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
RegisterStdioClient(fmt.Sprintf("plugin/%d", idx), nil)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
key := fmt.Sprintf("plugin/%d", i)
|
||||
if _, ok := LookupStdioClient(key); !ok {
|
||||
t.Errorf("LookupStdioClient(%q) missing after concurrent registration", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvePluginColdTimeouts covers the three code paths of the env
|
||||
// parser: unset (defaults), valid duration (applied to all three slots),
|
||||
// and invalid duration (logged and ignored, defaults returned).
|
||||
func TestResolvePluginColdTimeouts(t *testing.T) {
|
||||
t.Run("defaults when env unset", func(t *testing.T) {
|
||||
t.Setenv(cli.PluginColdTimeoutEnv, "")
|
||||
got := resolvePluginColdTimeouts()
|
||||
if got.httpNoAuth != 1*time.Second {
|
||||
t.Errorf("httpNoAuth = %v, want 1s", got.httpNoAuth)
|
||||
}
|
||||
if got.httpAuth != 1500*time.Millisecond {
|
||||
t.Errorf("httpAuth = %v, want 1.5s", got.httpAuth)
|
||||
}
|
||||
if got.stdio != 2*time.Second {
|
||||
t.Errorf("stdio = %v, want 2s", got.stdio)
|
||||
}
|
||||
})
|
||||
t.Run("env override applies to all slots", func(t *testing.T) {
|
||||
t.Setenv(cli.PluginColdTimeoutEnv, "3500ms")
|
||||
got := resolvePluginColdTimeouts()
|
||||
want := 3500 * time.Millisecond
|
||||
if got.httpNoAuth != want || got.httpAuth != want || got.stdio != want {
|
||||
t.Errorf("override not propagated: %+v", got)
|
||||
}
|
||||
})
|
||||
t.Run("invalid env falls back to defaults", func(t *testing.T) {
|
||||
t.Setenv(cli.PluginColdTimeoutEnv, "not-a-duration")
|
||||
got := resolvePluginColdTimeouts()
|
||||
if got.httpNoAuth != 1*time.Second || got.stdio != 2*time.Second {
|
||||
t.Errorf("invalid env should not override defaults: %+v", got)
|
||||
}
|
||||
})
|
||||
t.Run("non-positive env falls back to defaults", func(t *testing.T) {
|
||||
t.Setenv(cli.PluginColdTimeoutEnv, "0")
|
||||
got := resolvePluginColdTimeouts()
|
||||
if got.httpNoAuth != 1*time.Second {
|
||||
t.Errorf("zero duration should not override defaults, got %v", got.httpNoAuth)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// resolveStdioOverlay resolves the CLIOverlay for a stdio plugin server
|
||||
// from its manifest. It supports two forms:
|
||||
//
|
||||
// 1. inline JSON object in manifest.MCPServers[key].CLI
|
||||
// 2. a relative file path (JSON string) pointing to an external overlay
|
||||
// file anchored at the plugin root (e.g. "overlay.json")
|
||||
//
|
||||
// When no CLI metadata is present, a minimal overlay keyed by the server
|
||||
// name is returned so callers can still build an identity descriptor.
|
||||
func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) market.CLIOverlay {
|
||||
serverID := sc.Key
|
||||
overlay := market.CLIOverlay{
|
||||
ID: serverID,
|
||||
Command: serverID,
|
||||
}
|
||||
srv, ok := p.Manifest.MCPServers[sc.Key]
|
||||
if !ok || len(srv.CLI) == 0 {
|
||||
return overlay
|
||||
}
|
||||
|
||||
cliData := srv.CLI
|
||||
// A JSON string is interpreted as a relative path to an external
|
||||
// overlay file (e.g. "overlay.json") anchored at the plugin root.
|
||||
if len(cliData) > 0 && cliData[0] == '"' {
|
||||
var cliPath string
|
||||
if err := json.Unmarshal(cliData, &cliPath); err == nil && cliPath != "" {
|
||||
absPath := filepath.Join(p.Root, cliPath)
|
||||
if fileData, readErr := os.ReadFile(absPath); readErr == nil {
|
||||
cliData = fileData
|
||||
} else {
|
||||
slog.Warn("plugin: failed to read CLI overlay file",
|
||||
"plugin", p.Manifest.Name, "path", absPath, "error", readErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(cliData, &overlay); err != nil {
|
||||
slog.Warn("plugin: failed to parse CLI overlay for stdio server",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
}
|
||||
if overlay.ID == "" {
|
||||
overlay.ID = serverID
|
||||
}
|
||||
if overlay.Command == "" {
|
||||
overlay.Command = serverID
|
||||
}
|
||||
return overlay
|
||||
}
|
||||
|
||||
// toolsToDetails converts discovered ToolDescriptors to the DetailTool map
|
||||
// shape expected by compat.BuildDynamicCommands (keyed by overlay ID).
|
||||
// Returns nil if tools is empty.
|
||||
func toolsToDetails(tools []transport.ToolDescriptor, overlayID string) map[string][]market.DetailTool {
|
||||
if len(tools) == 0 {
|
||||
return nil
|
||||
}
|
||||
detailTools := make([]market.DetailTool, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
schemaJSON := ""
|
||||
if tool.InputSchema != nil {
|
||||
if data, marshalErr := json.Marshal(tool.InputSchema); marshalErr == nil {
|
||||
schemaJSON = string(data)
|
||||
}
|
||||
}
|
||||
detailTools = append(detailTools, market.DetailTool{
|
||||
ToolName: tool.Name,
|
||||
ToolTitle: tool.Title,
|
||||
ToolDesc: tool.Description,
|
||||
IsSensitive: tool.Sensitive,
|
||||
ToolRequest: schemaJSON,
|
||||
})
|
||||
}
|
||||
return map[string][]market.DetailTool{overlayID: detailTools}
|
||||
}
|
||||
|
||||
// registerStdioServerFromOverlay builds cobra commands for a stdio plugin
|
||||
// server using only its manifest + overlay.json — no subprocess required.
|
||||
//
|
||||
// Returns (cmds, descriptor, true) when the overlay carries toolOverrides,
|
||||
// otherwise (nil, zero, false) so the caller can fall back to discovery-first
|
||||
// registration (legacy path).
|
||||
//
|
||||
// When a warm tools cache exists for this server, its DetailTools are passed
|
||||
// to BuildDynamicCommands so flag types are enriched from the last successful
|
||||
// discovery. Fresh installs (or evicted caches) get overlay-declared flags
|
||||
// only; the next startup after a successful refresh picks up the full schema.
|
||||
func registerStdioServerFromOverlay(
|
||||
p *plugin.Plugin,
|
||||
sc plugin.StdioServerClient,
|
||||
runner executor.Runner,
|
||||
store *cache.Store,
|
||||
) ([]*cobra.Command, market.ServerDescriptor, bool) {
|
||||
overlay := resolveStdioOverlay(p, sc)
|
||||
if len(overlay.ToolOverrides) == 0 {
|
||||
return nil, market.ServerDescriptor{}, false
|
||||
}
|
||||
|
||||
descriptor := market.ServerDescriptor{
|
||||
Key: sc.Key,
|
||||
DisplayName: p.Manifest.Name + "/" + sc.Key,
|
||||
Description: p.Manifest.Description,
|
||||
Endpoint: StdioEndpoint(p.Manifest.Name, sc.Key),
|
||||
Source: "plugin",
|
||||
CLI: overlay,
|
||||
HasCLIMeta: true,
|
||||
}
|
||||
|
||||
AppendDynamicServer(descriptor)
|
||||
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
|
||||
|
||||
// Warm-cache enrichment: if a prior successful discovery wrote a
|
||||
// non-empty tool list, use its schema to enrich flag types.
|
||||
var detailsByID map[string][]market.DetailTool
|
||||
if store != nil {
|
||||
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
|
||||
if snapshot, _, err := store.LoadTools(config.DefaultPartition, cacheKey); err == nil && len(snapshot.Tools) > 0 {
|
||||
detailsByID = toolsToDetails(snapshot.Tools, overlay.ID)
|
||||
}
|
||||
}
|
||||
|
||||
cmds := compat.BuildDynamicCommands(
|
||||
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
|
||||
|
||||
slog.Debug("plugin: stdio server registered from overlay",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key,
|
||||
"toolOverrides", len(overlay.ToolOverrides),
|
||||
"commands", len(cmds),
|
||||
"enriched", detailsByID != nil)
|
||||
|
||||
return cmds, descriptor, true
|
||||
}
|
||||
|
||||
// refreshStdioToolsCache performs Initialize + ListTools on a stdio plugin
|
||||
// subprocess and persists the result so the next startup can enrich
|
||||
// overlay-registered commands with typed flags. It never constructs cobra
|
||||
// commands; command registration has already happened synchronously from
|
||||
// the overlay before this function runs.
|
||||
//
|
||||
// On failure (subprocess not ready, RPC timeout, empty tool list) it skips
|
||||
// SaveTools entirely so a transient error cannot poison the warm cache
|
||||
// with a null-tools snapshot.
|
||||
func refreshStdioToolsCache(
|
||||
p *plugin.Plugin,
|
||||
sc plugin.StdioServerClient,
|
||||
store *cache.Store,
|
||||
timeouts pluginColdTimeouts,
|
||||
) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
tools := discoverStdioTools(p, sc, timeouts)
|
||||
if len(tools) == 0 {
|
||||
slog.Debug("plugin: stdio cache refresh skipped (no tools)",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key)
|
||||
return
|
||||
}
|
||||
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
|
||||
if err := store.SaveTools(config.DefaultPartition, cacheKey, cache.ToolsSnapshot{
|
||||
ServerKey: cacheKey,
|
||||
Tools: tools,
|
||||
}); err != nil {
|
||||
slog.Warn("plugin: failed to persist stdio tools cache",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Debug("plugin: stdio tools cache refreshed",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "tools", len(tools))
|
||||
}
|
||||
|
||||
// hasOverlayToolOverrides reports whether a stdio plugin server carries
|
||||
// enough CLI metadata to be registered via the overlay-first path. Used by
|
||||
// loadPlugins to split entries into overlay-first vs. legacy discovery-first
|
||||
// buckets without doing the overlay parse twice.
|
||||
func hasOverlayToolOverrides(p *plugin.Plugin, sc plugin.StdioServerClient) bool {
|
||||
return len(resolveStdioOverlay(p, sc).ToolOverrides) > 0
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+791
-38
File diff suppressed because it is too large
Load Diff
@@ -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/bamboo":
|
||||
_ = 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/bamboo":
|
||||
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()
|
||||
|
||||
|
||||
@@ -29,6 +29,14 @@ import (
|
||||
mockmcp "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/test/mock_mcp"
|
||||
)
|
||||
|
||||
// patLikeError simulates an edition-specific PAT error that implements both
|
||||
// ExitCoder (exit code 4) and RawStderrError (raw JSON to stderr).
|
||||
type patLikeError struct{ raw string }
|
||||
|
||||
func (e *patLikeError) Error() string { return e.raw }
|
||||
func (e *patLikeError) ExitCode() int { return 4 }
|
||||
func (e *patLikeError) RawStderr() string { return e.raw }
|
||||
|
||||
func TestPrintExecutionErrorDefaultsToJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -43,11 +51,11 @@ func TestPrintExecutionErrorDefaultsToJSON(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty for JSON error output", stderr.String())
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stdout = %q, want JSON error payload", stdout.String())
|
||||
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,11 +73,11 @@ func TestPrintExecutionErrorUsesJSONWhenFormatIsJSON(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty for JSON error output", stderr.String())
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stdout = %q, want JSON error payload", stdout.String())
|
||||
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,11 +105,11 @@ func TestPrintExecutionErrorUsesJSONWhenCommandSetsJSONFlag(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty for JSON error output", stderr.String())
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stdout = %q, want JSON error payload", stdout.String())
|
||||
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +263,11 @@ 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"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("root help output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootShortHelpDoesNotRequirePINOrLogin(t *testing.T) {
|
||||
@@ -338,7 +351,90 @@ 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())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintExecutionError_RawStderrError_writes_raw_JSON_to_stderr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rawJSON := `{"success":false,"code":"PAT_LOW_RISK_NO_PERMISSION","data":{}}`
|
||||
err := &patLikeError{raw: rawJSON}
|
||||
|
||||
root := NewRootCommand()
|
||||
var stdout, stderr bytes.Buffer
|
||||
writeErr := printExecutionError(root, &stdout, &stderr, err)
|
||||
if writeErr != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", writeErr)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty for RawStderrError", stdout.String())
|
||||
}
|
||||
got := strings.TrimSpace(stderr.String())
|
||||
if got != rawJSON {
|
||||
t.Fatalf("stderr = %q, want raw JSON %q", got, rawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintExecutionError_RawStderrError_exit_code_is_4(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := &patLikeError{raw: `{"code":"PAT_MEDIUM_RISK_NO_PERMISSION"}`}
|
||||
exitCode := apperrors.ExitCode(err)
|
||||
if exitCode != 4 {
|
||||
t.Fatalf("apperrors.ExitCode(patLikeError) = %d, want 4", exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintExecutionError_RawStderrError_takes_precedence_over_JSON_mode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rawJSON := `{"success":false,"code":"PAT_HIGH_RISK_NO_PERMISSION"}`
|
||||
err := &patLikeError{raw: rawJSON}
|
||||
|
||||
root := NewRootCommand()
|
||||
_ = root.PersistentFlags().Set("format", "json")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
writeErr := printExecutionError(root, &stdout, &stderr, err)
|
||||
if writeErr != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", writeErr)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty — RawStderrError should bypass JSON mode", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "PAT_HIGH_RISK_NO_PERMISSION") {
|
||||
t.Fatalf("stderr = %q, want raw PAT JSON", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// simulateExecuteWithPanic mirrors the recovery pattern in Execute():
|
||||
// named return + defer recover → exitCode = 5 on panic.
|
||||
func simulateExecuteWithPanic(doPanic bool) (exitCode int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
exitCode = 5
|
||||
}
|
||||
}()
|
||||
if doPanic {
|
||||
panic("test panic")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func TestExecute_panic_recovery_returns_exit_5(t *testing.T) {
|
||||
t.Parallel()
|
||||
code := simulateExecuteWithPanic(true)
|
||||
if code != 5 {
|
||||
t.Fatalf("panic recovery exitCode = %d, want 5", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_no_panic_returns_0(t *testing.T) {
|
||||
t.Parallel()
|
||||
code := simulateExecuteWithPanic(false)
|
||||
if code != 0 {
|
||||
t.Fatalf("no-panic exitCode = %d, want 0", code)
|
||||
}
|
||||
}
|
||||
|
||||
+85
-11
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -14,6 +15,26 @@ func configureRootHelp(root *cobra.Command) {
|
||||
return
|
||||
}
|
||||
|
||||
// Replace the cobra-default English help command with a localized one so
|
||||
// that both its listing short (shown in `dws --help`) and its own
|
||||
// `dws help --help` long text follow the active locale.
|
||||
root.SetHelpCommand(&cobra.Command{
|
||||
Use: "help [command]",
|
||||
Short: i18n.T("查看任意命令的帮助信息"),
|
||||
Long: i18n.T("显示任意命令的帮助文案。\n" +
|
||||
"用法:dws help [命令路径] 查看完整说明。"),
|
||||
DisableAutoGenTag: true,
|
||||
Run: func(c *cobra.Command, args []string) {
|
||||
target, _, err := c.Root().Find(args)
|
||||
if target == nil || err != nil {
|
||||
c.Root().HelpFunc()(c.Root(), args)
|
||||
return
|
||||
}
|
||||
target.InitDefaultHelpFlag()
|
||||
_ = target.Help()
|
||||
},
|
||||
})
|
||||
|
||||
defaultHelpFunc := root.HelpFunc()
|
||||
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
if cmd != root {
|
||||
@@ -26,6 +47,7 @@ func configureRootHelp(root *cobra.Command) {
|
||||
|
||||
func renderRootHelp(root *cobra.Command) {
|
||||
services := visibleMCPRootCommands(root)
|
||||
utilities := visibleUtilityRootCommands(root)
|
||||
w := root.OutOrStdout()
|
||||
|
||||
if len(services) == 0 {
|
||||
@@ -45,8 +67,49 @@ func renderRootHelp(root *cobra.Command) {
|
||||
|
||||
_, _ = fmt.Fprintln(w, "Usage:")
|
||||
_, _ = fmt.Fprintln(w, " dws <service> [command] [flags]")
|
||||
if len(utilities) > 0 {
|
||||
_, _ = fmt.Fprintln(w, " dws <command> [flags]")
|
||||
}
|
||||
_, _ = fmt.Fprintln(w)
|
||||
_, _ = fmt.Fprintln(w, `Use "dws <service> --help" for more information about a discovered MCP service.`)
|
||||
if len(utilities) > 0 {
|
||||
_, _ = fmt.Fprintln(w, "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))
|
||||
}
|
||||
_ = 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.`)
|
||||
|
||||
// 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, long)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -54,16 +117,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
|
||||
}
|
||||
@@ -80,3 +134,23 @@ func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command {
|
||||
}
|
||||
return commands
|
||||
}
|
||||
|
||||
func visibleUtilityRootCommands(root *cobra.Command) []*cobra.Command {
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
productCommands := resolveVisibleProducts()
|
||||
|
||||
commands := make([]*cobra.Command, 0)
|
||||
for _, cmd := range root.Commands() {
|
||||
if cmd == nil || cmd.Hidden {
|
||||
continue
|
||||
}
|
||||
if productCommands[cmd.Name()] {
|
||||
continue
|
||||
}
|
||||
commands = append(commands, cmd)
|
||||
}
|
||||
return commands
|
||||
}
|
||||
|
||||
+383
-56
@@ -15,9 +15,10 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -29,11 +30,54 @@ import (
|
||||
"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/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_RUNTIME_CONTENT_SCAN",
|
||||
Category: configmeta.CategoryRuntime,
|
||||
Description: "启用 MCP 响应内容安全扫描",
|
||||
Example: "true",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_RUNTIME_CONTENT_SCAN_ENFORCE",
|
||||
Category: configmeta.CategoryRuntime,
|
||||
Description: "内容安全扫描发现问题时阻断响应",
|
||||
Example: "true",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_RUNTIME_CONTENT_SCAN_REPORT",
|
||||
Category: configmeta.CategoryRuntime,
|
||||
Description: "在 JSON 输出中包含安全扫描报告",
|
||||
Example: "true",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DINGTALK_AGENT",
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "业务 Agent 名称;仅用于 x-dingtalk-agent 请求头,与 claw-type/host-owned PAT 判定无关",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DINGTALK_TRACE_ID",
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "MCP 请求 x-dingtalk-trace-id 头",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DINGTALK_SESSION_ID",
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "MCP 请求 x-dingtalk-session-id 头",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DINGTALK_MESSAGE_ID",
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "MCP 请求 x-dingtalk-message-id 头",
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
runtimeContentScanEnv = "DWS_RUNTIME_CONTENT_SCAN"
|
||||
runtimeContentScanEnforceEnv = "DWS_RUNTIME_CONTENT_SCAN_ENFORCE"
|
||||
@@ -44,9 +88,44 @@ 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", os.Getenv(authpkg.AgentCodeEnv) != "",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) executor.Runner {
|
||||
// Ensure DWS_CLIENT_ID env is populated from persisted config before
|
||||
// resolveIdentityHeaders reads it. This covers fresh-process cold starts
|
||||
// where no env var has been inherited from a parent process.
|
||||
if os.Getenv("DWS_CLIENT_ID") == "" {
|
||||
if cid := authpkg.ClientID(); cid != "" {
|
||||
_ = os.Setenv("DWS_CLIENT_ID", cid)
|
||||
}
|
||||
}
|
||||
|
||||
var httpClient *http.Client
|
||||
if flags != nil && flags.Timeout > 0 {
|
||||
httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second}
|
||||
@@ -76,16 +155,16 @@ type runtimeRunner struct {
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
totalStart := time.Now()
|
||||
defer func() {
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] runtimeRunner.Run total: %v\n", time.Since(totalStart))
|
||||
}
|
||||
}()
|
||||
// Emit the one-shot host-owned PAT decision log. Placed here (not in
|
||||
// the constructor) so it fires AFTER PersistentPreRunE has configured
|
||||
// slog level per --debug / --verbose. The Once guard makes repeat
|
||||
// invocations within the same process free.
|
||||
logHostOwnedPATDecisionOnce()
|
||||
|
||||
if r.loader == nil || r.transport == nil {
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
r.transport.ExtraHeaders = resolveIdentityHeaders()
|
||||
|
||||
// Mock mode: skip catalog validation, use a placeholder endpoint.
|
||||
if r.globalFlags != nil && r.globalFlags.Mock {
|
||||
@@ -96,6 +175,11 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
return r.executeInvocation(ctx, endpoint, 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 shouldUseDirectRuntime(invocation) {
|
||||
if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok {
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
@@ -106,15 +190,29 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
catalog, err := r.loader.Load(ctx)
|
||||
RecordTiming(ctx, "catalog_load", time.Since(catalogStart))
|
||||
if err != nil {
|
||||
return executor.Result{}, err
|
||||
var degraded *cli.CatalogDegraded
|
||||
if !errors.As(err, °raded) {
|
||||
return executor.Result{}, err
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -124,24 +222,107 @@ 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)
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (executor.Result, error) {
|
||||
// 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)
|
||||
}
|
||||
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("产品 envelope 可能未下发到 discovery,或已经被 serverDeps fail-fast 丢弃;可执行 'dws cache refresh' 强制重新 discovery,仍失败请向 Portal 确认 envelope 状态。"),
|
||||
apperrors.WithActions("dws cache refresh"),
|
||||
)
|
||||
}
|
||||
|
||||
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) {
|
||||
return r.executeStdioInvocation(ctx, invocation)
|
||||
}
|
||||
|
||||
invokeStart := time.Now()
|
||||
execID := generateExecutionID()
|
||||
r.transport.ExecutionId = execID
|
||||
|
||||
// Lazy bind FileLogger: it may be nil at construction time because
|
||||
// configureLogLevel runs later in PersistentPreRunE.
|
||||
if r.transport.FileLogger == nil {
|
||||
r.transport.FileLogger = FileLoggerInstance()
|
||||
}
|
||||
|
||||
authStart := time.Now()
|
||||
authToken := r.resolveAuthToken(ctx)
|
||||
authDuration := time.Since(authStart)
|
||||
RecordTiming(ctx, "auth_token", authDuration)
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] resolveAuthToken: %v\n", authDuration)
|
||||
fl := r.transport.FileLogger
|
||||
|
||||
defer func() {
|
||||
var errCat, errReason string
|
||||
if retErr != nil {
|
||||
var typed *apperrors.Error
|
||||
if errors.As(retErr, &typed) {
|
||||
errCat = string(typed.Category)
|
||||
errReason = typed.Reason
|
||||
} else {
|
||||
errCat = "unknown"
|
||||
errReason = retErr.Error()
|
||||
}
|
||||
}
|
||||
logging.LogCommandEnd(fl, execID,
|
||||
invocation.CanonicalProduct, invocation.Tool,
|
||||
retErr == nil, time.Since(invokeStart), errCat, errReason)
|
||||
}()
|
||||
|
||||
// Check if this product has plugin-level auth credentials registered.
|
||||
// If so, use the plugin's token instead of the default DingTalk OAuth token.
|
||||
// This allows third-party MCP servers (e.g. Bailian) to use their own API keys.
|
||||
pluginAuth, hasPluginAuth := LookupPluginAuth(invocation.CanonicalProduct)
|
||||
|
||||
authToken := ""
|
||||
if hasPluginAuth {
|
||||
authToken = pluginAuth.Token
|
||||
} else {
|
||||
authToken = r.resolveAuthToken(ctx)
|
||||
}
|
||||
|
||||
var timeoutSec int
|
||||
if r.globalFlags != nil {
|
||||
timeoutSec = r.globalFlags.Timeout
|
||||
}
|
||||
logging.LogCommandStart(fl, execID,
|
||||
invocation.CanonicalProduct, invocation.Tool, endpoint, version, authToken != "", timeoutSec)
|
||||
|
||||
if invocation.DryRun {
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
@@ -182,28 +363,90 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
)
|
||||
}
|
||||
|
||||
tc := r.transport.WithAuth(authToken, resolveIdentityHeaders())
|
||||
|
||||
callStart := time.Now()
|
||||
callResult, err := tc.CallTool(ctx, endpoint, invocation.Tool, invocation.Params)
|
||||
callDuration := time.Since(callStart)
|
||||
RecordTiming(ctx, "mcp_call", callDuration)
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] MCP CallTool: %v\n", callDuration)
|
||||
var tc *transport.Client
|
||||
if hasPluginAuth {
|
||||
// Use plugin-level auth: inject the plugin's token and trust its domains.
|
||||
tc = r.transport.WithAuth(authToken, pluginAuth.ExtraHeaders)
|
||||
tc.TrustedDomains = pluginAuth.TrustedDomains
|
||||
} else {
|
||||
// Default path: use DingTalk OAuth token with identity headers.
|
||||
tc = r.transport.WithAuth(authToken, resolveIdentityHeaders())
|
||||
}
|
||||
if err != nil {
|
||||
if isAuthError(err) {
|
||||
if fn := edition.Get().OnAuthError; fn != nil {
|
||||
_ = fn(defaultConfigDir(), err)
|
||||
|
||||
callCtx := ctx
|
||||
if r.globalFlags != nil && r.globalFlags.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
callCtx, cancel = context.WithTimeout(ctx, time.Duration(r.globalFlags.Timeout)*time.Second)
|
||||
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))
|
||||
if err != nil {
|
||||
if isAuthError(err) {
|
||||
if fn := edition.Get().OnAuthError; fn != nil {
|
||||
if overrideErr := fn(defaultConfigDir(), err); overrideErr != nil {
|
||||
captureRuntimeFailure(invocation, err, overrideErr)
|
||||
return executor.Result{}, overrideErr
|
||||
}
|
||||
}
|
||||
}
|
||||
// PAT scope error: offer human-readable output and retry after authorization
|
||||
if isPatScopeError(err) {
|
||||
scopeErr := extractPatScopeError(err)
|
||||
captureRuntimeFailure(invocation, err, err)
|
||||
return retryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
captureRuntimeFailure(invocation, err, err)
|
||||
return executor.Result{}, err
|
||||
}
|
||||
|
||||
// ---- Edition hook gets first dibs (preserves overlay PATError passthrough) ----
|
||||
if fn := edition.Get().ClassifyToolResult; fn != nil {
|
||||
if editionErr := fn(callResult.Content); editionErr != nil {
|
||||
if patCheck := apperrors.AsPatAuthCheckError(editionErr); patCheck != nil {
|
||||
if IsPatRetrying(ctx) {
|
||||
return executor.Result{}, patCheck // already retried once, don't loop
|
||||
}
|
||||
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
return executor.Result{}, editionErr
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Structured PAT auth check (open-source fallback) ----
|
||||
if patCheck := apperrors.ClassifyPatAuthCheck(callResult.Content); patCheck != nil {
|
||||
if IsPatRetrying(ctx) {
|
||||
return executor.Result{}, patCheck // already retried once, don't loop
|
||||
}
|
||||
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
|
||||
if callResult.IsError {
|
||||
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
|
||||
logBusinessError(r.transport.FileLogger, "mcp_tool_error", invocation, callResult.Content, diag)
|
||||
|
||||
// ClassifyToolResult hook: let the overlay intercept known error
|
||||
// patterns (PAT permission, gateway-auth) before generic handling.
|
||||
if classify := edition.Get().ClassifyToolResult; classify != nil {
|
||||
if hookErr := classify(callResult.Content); hookErr != nil {
|
||||
captureRuntimeFailure(invocation, hookErr, hookErr)
|
||||
return executor.Result{}, hookErr
|
||||
}
|
||||
}
|
||||
|
||||
mcpErr := apperrors.NewAPI(
|
||||
extractMCPErrorMessage(callResult),
|
||||
apperrors.WithOperation("tools/call"),
|
||||
@@ -212,6 +455,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."),
|
||||
apperrors.WithServerDiag(diag),
|
||||
)
|
||||
// PAT scope error in business response: offer human-readable output and retry
|
||||
if isPatScopeError(mcpErr) {
|
||||
scopeErr := extractPatScopeError(mcpErr)
|
||||
captureRuntimeFailure(invocation, mcpErr, mcpErr)
|
||||
return retryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
captureRuntimeFailure(invocation, mcpErr, mcpErr)
|
||||
return executor.Result{}, mcpErr
|
||||
}
|
||||
@@ -244,12 +493,78 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
return executor.Result{Invocation: invocation, Response: response}, nil
|
||||
}
|
||||
|
||||
// executeStdioInvocation dispatches a tool call through a local StdioClient
|
||||
// subprocess instead of the HTTP transport. This is used for plugin stdio
|
||||
// servers whose endpoints use the stdio:// scheme.
|
||||
func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
if invocation.DryRun {
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"dry_run": true,
|
||||
"transport": "stdio",
|
||||
"request": executor.ToolCallRequest(invocation.Tool, invocation.Params),
|
||||
"note": "execution skipped by --dry-run",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
client, ok := LookupStdioClient(invocation.CanonicalProduct)
|
||||
if !ok {
|
||||
return executor.Result{}, apperrors.NewInternal(
|
||||
fmt.Sprintf("stdio client not found for %q", invocation.CanonicalProduct))
|
||||
}
|
||||
|
||||
callCtx := ctx
|
||||
if r.globalFlags != nil && r.globalFlags.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
callCtx, cancel = context.WithTimeout(ctx, time.Duration(r.globalFlags.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
callResult, err := client.CallTool(callCtx, invocation.Tool, invocation.Params)
|
||||
if err != nil {
|
||||
return executor.Result{}, apperrors.NewAPI(
|
||||
fmt.Sprintf("stdio call failed: %v", err),
|
||||
apperrors.WithOperation("tools/call"),
|
||||
apperrors.WithReason("stdio_error"),
|
||||
)
|
||||
}
|
||||
|
||||
if callResult.IsError {
|
||||
return executor.Result{}, apperrors.NewAPI(
|
||||
extractMCPErrorMessage(callResult),
|
||||
apperrors.WithOperation("tools/call"),
|
||||
apperrors.WithReason("mcp_tool_error"),
|
||||
apperrors.WithServerKey(invocation.CanonicalProduct),
|
||||
)
|
||||
}
|
||||
|
||||
invocation.Implemented = true
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"transport": "stdio",
|
||||
"content": callResult.Content,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) resolveAuthToken(ctx context.Context) string {
|
||||
explicitToken := ""
|
||||
if r != nil && r.globalFlags != nil {
|
||||
explicitToken = r.globalFlags.Token
|
||||
}
|
||||
return resolveRuntimeAuthToken(ctx, explicitToken)
|
||||
if token := strings.TrimSpace(explicitToken); token != "" {
|
||||
return token
|
||||
}
|
||||
if tp := edition.Get().TokenProvider; tp != nil {
|
||||
token, _ := tp(ctx, func() (string, error) {
|
||||
return resolveAccessTokenFromDir(ctx, defaultConfigDir())
|
||||
})
|
||||
return token
|
||||
}
|
||||
return getCachedRuntimeToken(ctx)
|
||||
}
|
||||
|
||||
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string {
|
||||
@@ -271,38 +586,30 @@ var (
|
||||
func getCachedRuntimeToken(ctx context.Context) string {
|
||||
cachedRuntimeTokenOnce.Do(func() {
|
||||
loadStart := time.Now()
|
||||
defer func() {
|
||||
loadDuration := time.Since(loadStart)
|
||||
RecordTiming(ctx, "keychain_load", loadDuration)
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] getCachedRuntimeToken (first load): %v\n", loadDuration)
|
||||
}
|
||||
}()
|
||||
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
provider := authpkg.NewOAuthProvider(configDir, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
token, tokenErr := provider.GetAccessToken(ctx)
|
||||
if tokenErr == nil && strings.TrimSpace(token) != "" {
|
||||
cachedRuntimeToken = strings.TrimSpace(token)
|
||||
return
|
||||
}
|
||||
// If the error is a decryption failure (corrupted data), log and bail out
|
||||
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
|
||||
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
|
||||
slog.Error(tokenErr.Error())
|
||||
return
|
||||
}
|
||||
// Try legacy manager as fallback
|
||||
manager := authpkg.NewManager(configDir, nil)
|
||||
configureLegacyAuthManagerCompatibility(manager)
|
||||
if token, _, err := manager.GetToken(); err == nil && strings.TrimSpace(token) != "" {
|
||||
cachedRuntimeToken = strings.TrimSpace(token)
|
||||
return
|
||||
if token != "" {
|
||||
cachedRuntimeToken = token
|
||||
}
|
||||
})
|
||||
return cachedRuntimeToken
|
||||
}
|
||||
|
||||
// generateExecutionID returns a random 16-char hex string used to correlate
|
||||
// all log entries (command_start, jsonrpc_request, command_end, etc.) belonging
|
||||
// to a single command invocation.
|
||||
func generateExecutionID() string {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access.
|
||||
// This should be called after login/logout operations.
|
||||
func ResetRuntimeTokenCache() {
|
||||
@@ -367,18 +674,38 @@ func resolveIdentityHeaders() map[string]string {
|
||||
headers = make(map[string]string)
|
||||
}
|
||||
|
||||
// Inject environment variable based headers for MCP gateway tracking
|
||||
// Inject environment variable based headers for MCP gateway tracking.
|
||||
// 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)
|
||||
}
|
||||
envHeaders := map[string]string{
|
||||
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
|
||||
"x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID),
|
||||
"x-dingtalk-session-id": os.Getenv(envDingtalkSessionID),
|
||||
"x-dingtalk-message-id": os.Getenv(envDingtalkMessageID),
|
||||
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
|
||||
"x-dingtalk-dws-agent-code": strings.TrimSpace(os.Getenv(authpkg.AgentCodeEnv)),
|
||||
"x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID),
|
||||
"x-dingtalk-session-id": sessionID,
|
||||
"x-dingtalk-message-id": os.Getenv(envDingtalkMessageID),
|
||||
}
|
||||
for k, v := range envHeaders {
|
||||
if v != "" {
|
||||
headers[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Inject third-party channel headers. DWS_CHANNEL is forwarded as the
|
||||
// upstream channelCode.
|
||||
if v := os.Getenv(envDWSChannel); v != "" {
|
||||
headers["x-dws-channel"] = v
|
||||
}
|
||||
|
||||
if fn := edition.Get().MergeHeaders; fn != nil {
|
||||
headers = fn(headers)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+415
-26
@@ -17,6 +17,8 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -24,13 +26,74 @@ 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"
|
||||
)
|
||||
|
||||
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())
|
||||
}))
|
||||
t.Cleanup(func() { discoverySrv.Close() })
|
||||
SetDiscoveryBaseURL(discoverySrv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
}
|
||||
|
||||
func contactDiscoveryResponse() map[string]any {
|
||||
return map[string]any{
|
||||
"metadata": map[string]any{"count": 1, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
map[string]any{
|
||||
"server": map[string]any{
|
||||
"name": "Contact",
|
||||
"description": "通讯录",
|
||||
"remotes": []any{
|
||||
map[string]any{
|
||||
"type": "streamable-http",
|
||||
"url": "https://mcp.dingtalk.com/contact/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"_meta": map[string]any{
|
||||
"com.dingtalk.mcp.registry/metadata": map[string]any{
|
||||
"status": "active", "isLatest": true,
|
||||
},
|
||||
"com.dingtalk.mcp.registry/cli": map[string]any{
|
||||
"id": "contact",
|
||||
"command": "contact",
|
||||
"groups": map[string]any{
|
||||
"user": map[string]any{
|
||||
"description": "用户管理",
|
||||
},
|
||||
},
|
||||
"toolOverrides": map[string]any{
|
||||
"get_current_user_profile": map[string]any{
|
||||
"cliName": "get-self",
|
||||
"group": "user",
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerIncludesContentScanReportWhenEnabled(t *testing.T) {
|
||||
@@ -255,6 +318,220 @@ func TestRuntimeRunnerInjectsAuthTokenFromFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv(authpkg.AgentCodeEnv, " cursor ")
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers["x-dingtalk-dws-agent-code"]; got != "cursor" {
|
||||
t.Fatalf("x-dingtalk-dws-agent-code = %q, want cursor", got)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -286,7 +563,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()
|
||||
@@ -299,33 +586,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,6 +866,125 @@ 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 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")
|
||||
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
|
||||
|
||||
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
|
||||
}
|
||||
method, _ := req["method"].(string)
|
||||
switch method {
|
||||
case "initialize":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req["id"],
|
||||
"result": map[string]any{
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": map[string]any{"tools": map[string]any{"listChanged": false}},
|
||||
"serverInfo": map[string]any{"name": "doc", "version": "1.0.0"},
|
||||
},
|
||||
})
|
||||
case "notifications/initialized":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case "tools/list":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req["id"],
|
||||
"result": map[string]any{
|
||||
"tools": []map[string]any{{
|
||||
"name": "search_documents",
|
||||
"title": "Search",
|
||||
"description": "Search documents",
|
||||
"inputSchema": map[string]any{"type": "object"},
|
||||
}},
|
||||
},
|
||||
})
|
||||
case "tools/call":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req["id"],
|
||||
"result": map[string]any{
|
||||
"content": map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_LOW_RISK_NO_PERMISSION",
|
||||
"data": map[string]any{"requiredScopes": []any{}},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
hookCalled := false
|
||||
sentinelMsg := "hook-intercepted-PAT"
|
||||
edition.Override(&edition.Hooks{
|
||||
ClassifyToolResult: func(content map[string]any) error {
|
||||
if code, ok := content["code"].(string); ok && strings.Contains(code, "PAT") {
|
||||
hookCalled = true
|
||||
return fmt.Errorf("%s", sentinelMsg)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(&edition.Hooks{}) })
|
||||
|
||||
t.Setenv(cli.CatalogFixtureEnv, writeDocCatalogFixture(t, server.URL, false))
|
||||
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("Execute() error = nil, want hook sentinel error")
|
||||
}
|
||||
if !hookCalled {
|
||||
t.Fatal("ClassifyToolResult hook was not called")
|
||||
}
|
||||
if !strings.Contains(err.Error(), sentinelMsg) {
|
||||
t.Fatalf("error = %q, want hook sentinel %q (not generic business error)", err.Error(), sentinelMsg)
|
||||
}
|
||||
if strings.Contains(err.Error(), "business_error") || strings.Contains(err.Error(), "mcp_tool_error") {
|
||||
t.Fatalf("error = %q, should NOT contain generic framework error category", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerReturnsErrorWhenMCPIsErrorTrue(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
|
||||
+336
-25
@@ -19,18 +19,35 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_SKILL_API_HOST",
|
||||
Category: configmeta.CategoryNetwork,
|
||||
Description: "覆盖 Skill API 地址",
|
||||
DefaultValue: "https://mcp.dingtalk.com",
|
||||
Example: "https://custom-mcp.example.com",
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
// legacySkillAPIHost is the legacy skill market host used by the old cli.
|
||||
legacySkillAPIHost = "https://mcp.dingtalk.com"
|
||||
// skillDownloadEndpoint is the API endpoint for downloading skills.
|
||||
skillDownloadEndpoint = "https://aihub.dingtalk.com/cli/download"
|
||||
// skillDownloadTimeout is the timeout for skill download operations.
|
||||
@@ -51,31 +68,103 @@ type downloadSkillResult struct {
|
||||
FileName string `json:"fileName"`
|
||||
}
|
||||
|
||||
// findSkillsResponse represents the legacy skill search API response.
|
||||
type findSkillsResponse struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
Result []CliSkillDTO `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// CliSkillDTO mirrors the old cli response payload for `skill search`.
|
||||
type CliSkillDTO struct {
|
||||
SkillID string `json:"skillId"`
|
||||
Name string `json:"name"`
|
||||
Desc string `json:"desc"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
|
||||
// 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{
|
||||
// `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",
|
||||
"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",
|
||||
Short: "技能管理",
|
||||
Long: "管理钉钉技能市场的技能。支持下载和安装技能到指定 Agent 目录。",
|
||||
Long: "管理钉钉技能市场的技能。支持搜索、下载与安装到指定 Agent 目录。",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
@@ -84,13 +173,62 @@ func buildSkillCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(newSkillAddCommand())
|
||||
cmd.AddCommand(
|
||||
newSkillInstallCommand(),
|
||||
newSkillGetCommand(),
|
||||
newSkillSearchCommand(),
|
||||
newSkillFindHintCommand(),
|
||||
newSkillAddHintCommand(),
|
||||
newSkillSetupCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSkillAddCommand() *cobra.Command {
|
||||
func newSkillGetCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add <skillId> <target>",
|
||||
Use: "get",
|
||||
Short: "获取技能压缩文件",
|
||||
Long: "从服务端下载技能包到本地临时目录。命令执行成功后会输出临时目录路径,供调用方使用。",
|
||||
Example: " dws skill get --skill-id <skillId>",
|
||||
DisableAutoGenTag: true,
|
||||
RunE: runSkillGet,
|
||||
}
|
||||
cmd.Flags().String("skill-id", "", "技能 ID(必填)")
|
||||
_ = cmd.MarkFlagRequired("skill-id")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSkillSearchCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "从钉钉技能市场搜索技能",
|
||||
Long: "从钉钉技能市场搜索技能,根据关键词返回匹配的技能列表。",
|
||||
Example: " dws skill search --query 关键词",
|
||||
DisableAutoGenTag: true,
|
||||
RunE: runSkillFind,
|
||||
}
|
||||
cmd.Flags().String("query", "", "搜索关键词(必填)")
|
||||
_ = cmd.MarkFlagRequired("query")
|
||||
cmd.Flags().String("scopes", "", "查询范围,空格分隔。备选值:DingtalkMarket(钉钉市场)、OrgInternal(企业内部)。为空默认查市场技能")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSkillFindHintCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "find",
|
||||
Short: "兼容旧用法,提示使用 skill search",
|
||||
Hidden: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "use: dws skill search --query <关键词>")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newSkillInstallCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "install <skillId> <target>",
|
||||
Short: "下载并安装技能到指定目录",
|
||||
Long: fmt.Sprintf(`从钉钉技能市场下载技能并安装到指定 Agent 目录。
|
||||
|
||||
@@ -99,17 +237,15 @@ func newSkillAddCommand() *cobra.Command {
|
||||
target 安装目标(必填),支持: %s
|
||||
|
||||
安装路径:
|
||||
qoder -> ~/.qoder/skills/
|
||||
claude -> ~/.claude/skills/
|
||||
cursor -> ~/.cursor/skills/
|
||||
codex -> ~/.codex/skills/
|
||||
opencode -> ~/.config/opencode/skills/
|
||||
. -> 当前目录
|
||||
%s .%s -> 当前目录
|
||||
|
||||
示例:
|
||||
dws skill add skill-123 qoder # 安装到 ~/.qoder/skills/
|
||||
dws skill add skill-123 claude # 安装到 ~/.claude/skills/
|
||||
dws skill add skill-123 . # 安装到当前目录`, supportedTargets()),
|
||||
dws skill install skill-123 claude # 安装到 ~/.claude/skills/
|
||||
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,
|
||||
@@ -118,6 +254,96 @@ func newSkillAddCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSkillAddHintCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "兼容旧用法,提示使用 skill install",
|
||||
Hidden: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "use: dws skill install <skillId> <target>")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runSkillGet(cmd *cobra.Command, args []string) error {
|
||||
skillID, _ := cmd.Flags().GetString("skill-id")
|
||||
accessToken, err := loadSkillAccessToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%s/cli/install?skillId=%s", skillAPIHost(), url.QueryEscape(strings.TrimSpace(skillID)))
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "⬇️ 下载技能包...")
|
||||
|
||||
tmpDir, err := downloadSkillToTmpDir(cmd.Context(), apiURL, accessToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), tmpDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSkillFind(cmd *cobra.Command, args []string) error {
|
||||
keyword, _ := cmd.Flags().GetString("query")
|
||||
scopes, _ := cmd.Flags().GetString("scopes")
|
||||
accessToken, err := loadSkillAccessToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%s/cli/find-skills?keyword=%s", skillAPIHost(), url.QueryEscape(strings.TrimSpace(keyword)))
|
||||
if scopes != "" {
|
||||
apiURL += "&scopes=" + url.QueryEscape(scopes)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
|
||||
}
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return apperrors.NewAPI(fmt.Sprintf("failed to search skills: %v", err), apperrors.WithRetryable(true))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return parseLegacySkillAPIError(resp)
|
||||
}
|
||||
|
||||
var result findSkillsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return apperrors.NewAPI(fmt.Sprintf("failed to parse search response: %v", err))
|
||||
}
|
||||
if !result.Success {
|
||||
errMsg := strings.TrimSpace(result.ErrorMsg)
|
||||
if errMsg == "" {
|
||||
errMsg = strings.TrimSpace(result.ErrorCode)
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = "unknown error"
|
||||
}
|
||||
return apperrors.NewAPI(fmt.Sprintf("failed to search skills: %s", errMsg))
|
||||
}
|
||||
|
||||
if len(result.Result) == 0 {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "未找到匹配的技能")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, skill := range result.Result {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "SkillID: %s\n", skill.SkillID)
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Name: %s\n", skill.Name)
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Desc: %s\n", skill.Desc)
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "---")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSkillAdd(cmd *cobra.Command, args []string) error {
|
||||
skillID := strings.TrimSpace(args[0])
|
||||
target := strings.TrimSpace(args[1])
|
||||
@@ -132,13 +358,9 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
|
||||
return apperrors.NewValidation(fmt.Sprintf("invalid target '%s': %v. Supported targets: %s", target, err, supportedTargets()))
|
||||
}
|
||||
|
||||
// Load auth token
|
||||
configDir := defaultConfigDir()
|
||||
tokenData, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil || tokenData == nil || !tokenData.IsAccessTokenValid() {
|
||||
return apperrors.NewAuth("not logged in or token expired. Please run 'dws auth login' first",
|
||||
apperrors.WithHint("请先执行 'dws auth login' 登录"),
|
||||
apperrors.WithActions("dws auth login"))
|
||||
accessToken, err := loadSkillAccessToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), skillDownloadTimeout)
|
||||
@@ -148,7 +370,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Step 1: Get download URL from API
|
||||
fmt.Fprintf(w, "正在获取技能信息...\n")
|
||||
downloadResp, err := fetchSkillDownloadInfo(ctx, tokenData.AccessToken, skillID)
|
||||
downloadResp, err := fetchSkillDownloadInfo(ctx, accessToken, skillID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -189,6 +411,33 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadSkillAccessToken() (string, error) {
|
||||
configDir := defaultConfigDir()
|
||||
tokenData, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil || tokenData == nil || !tokenData.IsAccessTokenValid() {
|
||||
return "", skillAuthError()
|
||||
}
|
||||
return tokenData.AccessToken, nil
|
||||
}
|
||||
|
||||
func skillAuthError() error {
|
||||
if edition.Get().IsEmbedded {
|
||||
return apperrors.NewAuth("认证信息已失效",
|
||||
apperrors.WithReason("not_authenticated"),
|
||||
apperrors.WithHint("请先完成钉钉账号登录后重试"))
|
||||
}
|
||||
return apperrors.NewAuth("not logged in or token expired. Please run 'dws auth login' first",
|
||||
apperrors.WithHint("请先执行 'dws auth login' 登录"),
|
||||
apperrors.WithActions("dws auth login"))
|
||||
}
|
||||
|
||||
func skillAPIHost() string {
|
||||
if override := strings.TrimSpace(os.Getenv("DWS_SKILL_API_HOST")); override != "" {
|
||||
return strings.TrimRight(override, "/")
|
||||
}
|
||||
return legacySkillAPIHost
|
||||
}
|
||||
|
||||
// resolveSkillTargetPath resolves the target argument to an absolute path.
|
||||
func resolveSkillTargetPath(target string) (string, error) {
|
||||
target = strings.TrimSpace(target)
|
||||
@@ -236,9 +485,7 @@ func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (*
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return nil, apperrors.NewAuth("authentication failed. Please run 'dws auth login' to refresh your token",
|
||||
apperrors.WithHint("请执行 'dws auth login' 重新登录"),
|
||||
apperrors.WithActions("dws auth login"))
|
||||
return nil, skillAuthError()
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -259,6 +506,70 @@ func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (*
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func downloadSkillToTmpDir(ctx context.Context, apiURL, accessToken string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
|
||||
}
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
|
||||
client := &http.Client{Timeout: skillDownloadTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", apperrors.NewAPI(fmt.Sprintf("failed to download skill package: %v", err), apperrors.WithRetryable(true))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", parseLegacySkillAPIError(resp)
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "dws-skill-*")
|
||||
if err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp dir: %v", err))
|
||||
}
|
||||
|
||||
filename := filenameFromDisposition(resp.Header.Get("Content-Disposition"))
|
||||
destPath := filepath.Join(tmpDir, filename)
|
||||
file, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp file: %v", err))
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := io.Copy(file, resp.Body); err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
return "", apperrors.NewAPI(fmt.Sprintf("failed to save downloaded file: %v", err))
|
||||
}
|
||||
return tmpDir, nil
|
||||
}
|
||||
|
||||
func filenameFromDisposition(cd string) string {
|
||||
if cd != "" {
|
||||
if _, params, err := mime.ParseMediaType(cd); err == nil {
|
||||
if name := strings.TrimSpace(params["filename"]); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
return "skill.zip"
|
||||
}
|
||||
|
||||
func parseLegacySkillAPIError(resp *http.Response) error {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusUnauthorized:
|
||||
return skillAuthError()
|
||||
case http.StatusBadRequest:
|
||||
return apperrors.NewValidation("request parameters are invalid")
|
||||
case http.StatusNotFound:
|
||||
return apperrors.NewValidation("skill does not exist or corresponding file was not found")
|
||||
default:
|
||||
return apperrors.NewAPI(fmt.Sprintf("skill API returned HTTP %d", resp.StatusCode),
|
||||
apperrors.WithRetryable(resp.StatusCode >= 500))
|
||||
}
|
||||
}
|
||||
|
||||
// downloadSkillFile downloads the skill zip file to a temporary location.
|
||||
func downloadSkillFile(ctx context.Context, downloadURL, fileName string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
|
||||
@@ -319,7 +319,7 @@ func TestExtractSkillZipPreventZipSlip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillAddCommandValidation(t *testing.T) {
|
||||
func TestSkillInstallCommandValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
@@ -328,19 +328,19 @@ func TestSkillAddCommandValidation(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "missing arguments",
|
||||
args: []string{"skill", "add"},
|
||||
args: []string{"skill", "install"},
|
||||
wantErr: true,
|
||||
errMsg: "accepts 2 arg(s)",
|
||||
},
|
||||
{
|
||||
name: "missing target",
|
||||
args: []string{"skill", "add", "skill-123"},
|
||||
args: []string{"skill", "install", "skill-123"},
|
||||
wantErr: true,
|
||||
errMsg: "accepts 2 arg(s)",
|
||||
},
|
||||
{
|
||||
name: "too many arguments",
|
||||
args: []string{"skill", "add", "skill-123", "qoder", "extra"},
|
||||
args: []string{"skill", "install", "skill-123", "qoder", "extra"},
|
||||
wantErr: true,
|
||||
errMsg: "accepts 2 arg(s)",
|
||||
},
|
||||
@@ -366,7 +366,7 @@ func TestSkillAddCommandValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillAddInvalidTarget(t *testing.T) {
|
||||
func TestSkillInstallInvalidTarget(t *testing.T) {
|
||||
// Setup: Create config directory with valid token
|
||||
tempDir := t.TempDir()
|
||||
configDir := filepath.Join(tempDir, "config")
|
||||
@@ -380,11 +380,11 @@ func TestSkillAddInvalidTarget(t *testing.T) {
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to save token data: %v", err)
|
||||
t.Skipf("SaveTokenData() unavailable in this environment: %v", err)
|
||||
}
|
||||
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "add", "skill-123", "invalid-target"})
|
||||
cmd.SetArgs([]string{"skill", "install", "skill-123", "invalid-target"})
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
@@ -399,7 +399,7 @@ func TestSkillAddInvalidTarget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillAddRequiresAuth(t *testing.T) {
|
||||
func TestSkillInstallRequiresAuth(t *testing.T) {
|
||||
// Setup: Create config directory without token
|
||||
tempDir := t.TempDir()
|
||||
configDir := filepath.Join(tempDir, "config")
|
||||
@@ -411,7 +411,7 @@ func TestSkillAddRequiresAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "add", "skill-123", "qoder"})
|
||||
cmd.SetArgs([]string{"skill", "install", "skill-123", "qoder"})
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
@@ -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 {
|
||||
@@ -563,14 +590,16 @@ func TestSkillCommandHelp(t *testing.T) {
|
||||
if !strings.Contains(output, "技能") {
|
||||
t.Errorf("help should mention '技能', got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "add") {
|
||||
t.Errorf("help should mention 'add' subcommand, got: %s", output)
|
||||
for _, subcmd := range []string{"install", "search", "get"} {
|
||||
if !strings.Contains(output, subcmd) {
|
||||
t.Errorf("help should mention %q subcommand, got: %s", subcmd, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillAddCommandHelp(t *testing.T) {
|
||||
func TestSkillInstallCommandHelp(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "add", "--help"})
|
||||
cmd.SetArgs([]string{"skill", "install", "--help"})
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
@@ -590,6 +619,56 @@ func TestSkillAddCommandHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillGetCommandValidation(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "get"})
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("Execute() error = nil, want missing required flag error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "required flag") {
|
||||
t.Fatalf("error = %v, want required flag message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSearchCommandValidation(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "search"})
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("Execute() error = nil, want missing required flag error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "required flag") {
|
||||
t.Fatalf("error = %v, want required flag message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillFindHintCommand(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "find"})
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "dws skill search --query") {
|
||||
t.Fatalf("output = %q, want legacy hint", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadSkillFileSuccess(t *testing.T) {
|
||||
// Create a mock server that returns a zip file
|
||||
expectedContent := []byte("fake zip content")
|
||||
|
||||
@@ -0,0 +1,647 @@
|
||||
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",
|
||||
".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 目录。`,
|
||||
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 源目录(默认自动查找二进制旁边或当前目录)")
|
||||
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, err := resolveSkillSetupSource(source, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
multiSkillNames = filtered
|
||||
}
|
||||
|
||||
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-"
|
||||
|
||||
// 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"
|
||||
|
||||
candidates := skillSourceCandidates(explicit, 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 {
|
||||
fi, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
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 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
|
||||
// successfully discovers ~/.dws/skills/multi/ as the source.
|
||||
func TestResolveSkillSetupSourceFallsBackToUserCache(t *testing.T) {
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("DWS_SKILL_SOURCE", "")
|
||||
|
||||
cacheRoot := filepath.Join(fakeHome, ".dws", "skills", "multi")
|
||||
for _, n := range []string{"dingtalk-aitable", "dingtalk-doc"} {
|
||||
if err := os.MkdirAll(filepath.Join(cacheRoot, n), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(cacheRoot, n, "SKILL.md"), []byte("# "+n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Run resolver from a tempdir that has no skills/ on disk, simulating a
|
||||
// fresh user machine without a source checkout.
|
||||
scratch := t.TempDir()
|
||||
t.Chdir(scratch)
|
||||
|
||||
got, err := resolveSkillSetupSource("", skillSetupModeMulti)
|
||||
if err != nil {
|
||||
t.Fatalf("expected user-cache fallback to succeed, got err=%v", err)
|
||||
}
|
||||
if got != cacheRoot {
|
||||
t.Fatalf("expected %s, got %s", cacheRoot, got)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// 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 (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
const stdioEndpointScheme = "stdio://"
|
||||
|
||||
var (
|
||||
stdioMu sync.RWMutex
|
||||
stdioClients = make(map[string]*transport.StdioClient)
|
||||
)
|
||||
|
||||
// RegisterStdioClient stores a StdioClient keyed by its canonical product ID
|
||||
// (the CLI.ID used in the server descriptor). The runner looks up this client
|
||||
// when a stdio:// endpoint is resolved at execution time.
|
||||
func RegisterStdioClient(productID string, client *transport.StdioClient) {
|
||||
stdioMu.Lock()
|
||||
defer stdioMu.Unlock()
|
||||
stdioClients[productID] = client
|
||||
}
|
||||
|
||||
// LookupStdioClient returns the StdioClient registered for the given product ID.
|
||||
// The productID can be either the full key (pluginName/serverKey) or just the serverKey.
|
||||
// This supports backward compatibility with existing CanonicalProduct values.
|
||||
func LookupStdioClient(productID string) (*transport.StdioClient, bool) {
|
||||
stdioMu.RLock()
|
||||
defer stdioMu.RUnlock()
|
||||
// Try exact match first
|
||||
if c, ok := stdioClients[productID]; ok {
|
||||
return c, true
|
||||
}
|
||||
// If not found, try matching by serverKey suffix (for backward compatibility)
|
||||
for id, c := range stdioClients {
|
||||
if idx := strings.LastIndex(id, "/"); idx >= 0 {
|
||||
if id[idx+1:] == productID {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// StdioEndpoint returns a virtual endpoint URL for a stdio-based MCP server.
|
||||
// Format: stdio://{pluginName}/{serverKey}
|
||||
func StdioEndpoint(pluginName, serverKey string) string {
|
||||
return stdioEndpointScheme + pluginName + "/" + serverKey
|
||||
}
|
||||
|
||||
// IsStdioEndpoint returns true if the endpoint uses the stdio:// scheme.
|
||||
func IsStdioEndpoint(endpoint string) bool {
|
||||
return strings.HasPrefix(endpoint, stdioEndpointScheme)
|
||||
}
|
||||
|
||||
// StopAllStdioClients stops all registered stdio clients.
|
||||
// This should be called on program exit to terminate child processes.
|
||||
func StopAllStdioClients() {
|
||||
stdioMu.Lock()
|
||||
defer stdioMu.Unlock()
|
||||
for id, client := range stdioClients {
|
||||
if err := client.Stop(); err != nil {
|
||||
slog.Warn("failed to stop stdio client", "id", id, "error", err)
|
||||
}
|
||||
}
|
||||
stdioClients = make(map[string]*transport.StdioClient)
|
||||
}
|
||||
|
||||
// StopStdioClient stops a specific stdio client by product ID.
|
||||
// Returns true if the client was found and stopped, false otherwise.
|
||||
func StopStdioClient(productID string) bool {
|
||||
stdioMu.Lock()
|
||||
defer stdioMu.Unlock()
|
||||
client, ok := stdioClients[productID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if err := client.Stop(); err != nil {
|
||||
slog.Warn("failed to stop stdio client", "id", productID, "error", err)
|
||||
}
|
||||
delete(stdioClients, productID)
|
||||
return true
|
||||
}
|
||||
|
||||
// StopStdioClientsByPlugin stops all stdio clients belonging to a plugin.
|
||||
// The productID format is "pluginName/serverKey". This function stops all
|
||||
// clients whose productID has the given pluginName prefix.
|
||||
func StopStdioClientsByPlugin(pluginName string) int {
|
||||
stdioMu.Lock()
|
||||
defer stdioMu.Unlock()
|
||||
prefix := pluginName + "/"
|
||||
count := 0
|
||||
for id, client := range stdioClients {
|
||||
if len(id) > len(prefix) && id[:len(prefix)] == prefix {
|
||||
if err := client.Stop(); err != nil {
|
||||
slog.Warn("failed to stop stdio client", "id", id, "error", err)
|
||||
}
|
||||
delete(stdioClients, id)
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// 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/transport"
|
||||
)
|
||||
|
||||
func TestStdioEndpoint(t *testing.T) {
|
||||
endpoint := StdioEndpoint("hello-plugin", "hello")
|
||||
want := "stdio://hello-plugin/hello"
|
||||
if endpoint != want {
|
||||
t.Errorf("StdioEndpoint() = %q, want %q", endpoint, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsStdioEndpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
endpoint string
|
||||
want bool
|
||||
}{
|
||||
{"stdio://hello-plugin/hello", true},
|
||||
{"stdio://conference/local", true},
|
||||
{"https://mcp.dingtalk.com", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := IsStdioEndpoint(tt.endpoint); got != tt.want {
|
||||
t.Errorf("IsStdioEndpoint(%q) = %v, want %v", tt.endpoint, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioClientRegistry(t *testing.T) {
|
||||
// Clean up after test
|
||||
defer func() {
|
||||
stdioMu.Lock()
|
||||
delete(stdioClients, "test-product")
|
||||
stdioMu.Unlock()
|
||||
}()
|
||||
|
||||
// Initially not found
|
||||
if _, ok := LookupStdioClient("test-product"); ok {
|
||||
t.Error("expected LookupStdioClient to return false for unregistered product")
|
||||
}
|
||||
|
||||
// Register a client
|
||||
client := transport.NewStdioClient("echo", nil, nil)
|
||||
RegisterStdioClient("test-product", client)
|
||||
|
||||
// Now should be found
|
||||
got, ok := LookupStdioClient("test-product")
|
||||
if !ok {
|
||||
t.Fatal("expected LookupStdioClient to return true after registration")
|
||||
}
|
||||
if got != client {
|
||||
t.Error("LookupStdioClient returned different client instance")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+215
-11
@@ -15,16 +15,45 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
)
|
||||
|
||||
// Environment variable to enable performance timing output.
|
||||
const PerfTimingEnv = "DWS_PERF_TIMING"
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_PERF_DEBUG",
|
||||
Category: configmeta.CategoryDebug,
|
||||
Description: "启用性能计时输出到 stderr",
|
||||
Example: "1",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_PERF_REPORT",
|
||||
Category: configmeta.CategoryDebug,
|
||||
Description: "JSON 性能报告输出路径 (auto=~/.dws/perf/latest.json)",
|
||||
Example: "auto",
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
// PerfDebugEnv is the environment variable to enable performance timing output.
|
||||
PerfDebugEnv = "DWS_PERF_DEBUG"
|
||||
|
||||
// PerfReportEnv is the environment variable to enable JSON perf report output.
|
||||
// Set to "auto" to write to ~/.dws/perf/latest.json, or a custom file path.
|
||||
PerfReportEnv = "DWS_PERF_REPORT"
|
||||
|
||||
perfReportDir = "perf"
|
||||
perfReportFile = "latest.json"
|
||||
)
|
||||
|
||||
// timingContextKey is the context key for TimingCollector.
|
||||
type timingContextKey struct{}
|
||||
@@ -107,32 +136,46 @@ func (tc *TimingCollector) Entries() []TimingEntry {
|
||||
return result
|
||||
}
|
||||
|
||||
// formatDuration returns a human-friendly duration string.
|
||||
// Sub-µs → "0µs", sub-ms → microsecond precision (e.g. "142µs"), else → ms.
|
||||
func formatDuration(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Microsecond:
|
||||
return "0µs"
|
||||
case d < time.Millisecond:
|
||||
return d.Truncate(time.Microsecond).String()
|
||||
default:
|
||||
return d.Truncate(time.Millisecond).String()
|
||||
}
|
||||
}
|
||||
|
||||
// Print writes a summary of all timing entries to the given writer.
|
||||
func (tc *TimingCollector) Print(w io.Writer) {
|
||||
if tc == nil || w == nil {
|
||||
return
|
||||
}
|
||||
entries := tc.Entries()
|
||||
total := tc.Total()
|
||||
if len(entries) == 0 {
|
||||
fmt.Fprintf(w, "\n[Timing] Total: %v (no detailed entries)\n", tc.Total().Truncate(time.Millisecond))
|
||||
fmt.Fprintf(w, "\n[Perf] Total: %v (no detailed entries)\n", formatDuration(total))
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "[Timing] Execution breakdown:")
|
||||
fmt.Fprintln(w, "[Perf] Execution breakdown:")
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(w, " %-30s %v\n", e.Name, e.Duration.Truncate(time.Millisecond))
|
||||
fmt.Fprintf(w, " %-30s %v\n", e.Name, formatDuration(e.Duration))
|
||||
}
|
||||
fmt.Fprintf(w, " %-30s %v\n", "──────────────────────────────", "──────────")
|
||||
fmt.Fprintf(w, " %-30s %v\n", "Total", tc.Total().Truncate(time.Millisecond))
|
||||
fmt.Fprintf(w, " %-30s %v\n", "Total", formatDuration(total))
|
||||
}
|
||||
|
||||
// PrintIfEnabled prints timing info to stderr if DWS_PERF_TIMING is set.
|
||||
// PrintIfEnabled prints timing info to stderr if DWS_PERF_DEBUG is set.
|
||||
func (tc *TimingCollector) PrintIfEnabled() {
|
||||
if tc == nil {
|
||||
return
|
||||
}
|
||||
if os.Getenv(PerfTimingEnv) == "" {
|
||||
if os.Getenv(PerfDebugEnv) == "" {
|
||||
return
|
||||
}
|
||||
tc.Print(os.Stderr)
|
||||
@@ -171,7 +214,168 @@ func StartTiming(ctx context.Context, name string) func() {
|
||||
return tc.StartTimer(name)
|
||||
}
|
||||
|
||||
// IsPerfTimingEnabled returns true if performance timing output is enabled.
|
||||
func IsPerfTimingEnabled() bool {
|
||||
return os.Getenv(PerfTimingEnv) != ""
|
||||
// IsPerfDebugEnabled returns true if performance debug output is enabled.
|
||||
func IsPerfDebugEnabled() bool {
|
||||
return os.Getenv(PerfDebugEnv) != ""
|
||||
}
|
||||
|
||||
// ── Structured Performance Report ──────────────────────────────────────
|
||||
|
||||
// PerfPhase is a single phase in the performance report.
|
||||
type PerfPhase struct {
|
||||
Name string `json:"name"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
Seq int `json:"seq"`
|
||||
}
|
||||
|
||||
// PerfReport is the JSON-serialisable performance report.
|
||||
type PerfReport struct {
|
||||
Kind string `json:"kind"`
|
||||
Version string `json:"version"`
|
||||
CLIVersion string `json:"cli_version"`
|
||||
Command string `json:"command"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
TotalMs int64 `json:"total_ms"`
|
||||
Phases []PerfPhase `json:"phases"`
|
||||
Slowest string `json:"slowest"`
|
||||
OverheadMs int64 `json:"overhead_ms"`
|
||||
}
|
||||
|
||||
// BuildReport constructs a PerfReport from the collected timing entries.
|
||||
func (tc *TimingCollector) BuildReport(cliVersion, command string) PerfReport {
|
||||
entries := tc.Entries()
|
||||
total := tc.Total()
|
||||
totalMs := total.Milliseconds()
|
||||
|
||||
phases := make([]PerfPhase, len(entries))
|
||||
var sumMs int64
|
||||
var slowestName string
|
||||
var slowestMs int64
|
||||
|
||||
for i, e := range entries {
|
||||
ms := e.Duration.Milliseconds()
|
||||
phases[i] = PerfPhase{
|
||||
Name: e.Name,
|
||||
DurationMs: ms,
|
||||
Seq: e.Seq,
|
||||
}
|
||||
sumMs += ms
|
||||
if ms > slowestMs {
|
||||
slowestMs = ms
|
||||
slowestName = e.Name
|
||||
}
|
||||
}
|
||||
|
||||
overhead := totalMs - sumMs
|
||||
if overhead < 0 {
|
||||
overhead = 0
|
||||
}
|
||||
|
||||
return PerfReport{
|
||||
Kind: "perf_report",
|
||||
Version: "1",
|
||||
CLIVersion: cliVersion,
|
||||
Command: command,
|
||||
Timestamp: time.Now(),
|
||||
TotalMs: totalMs,
|
||||
Phases: phases,
|
||||
Slowest: slowestName,
|
||||
OverheadMs: overhead,
|
||||
}
|
||||
}
|
||||
|
||||
// WriteReportIfEnabled checks DWS_PERF_REPORT and writes a JSON report if set.
|
||||
func (tc *TimingCollector) WriteReportIfEnabled(cliVersion, command string) {
|
||||
if tc == nil {
|
||||
return
|
||||
}
|
||||
dest := os.Getenv(PerfReportEnv)
|
||||
if dest == "" {
|
||||
return
|
||||
}
|
||||
|
||||
report := tc.BuildReport(cliVersion, command)
|
||||
data, err := json.MarshalIndent(report, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
path := resolvePerfReportPath(dest)
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return
|
||||
}
|
||||
_ = os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// LoadLatestReport reads the default perf report file (~/.dws/perf/latest.json).
|
||||
func LoadLatestReport() (*PerfReport, error) {
|
||||
path := defaultPerfReportPath()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var report PerfReport
|
||||
if err := json.Unmarshal(data, &report); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &report, nil
|
||||
}
|
||||
|
||||
// resolvePerfReportPath resolves the DWS_PERF_REPORT value to an absolute path.
|
||||
func resolvePerfReportPath(dest string) string {
|
||||
if dest == "auto" {
|
||||
return defaultPerfReportPath()
|
||||
}
|
||||
return dest
|
||||
}
|
||||
|
||||
func defaultPerfReportPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".dws", perfReportDir, perfReportFile)
|
||||
}
|
||||
|
||||
// sensitiveFlags are flag names whose values should be masked in commands.
|
||||
var sensitiveFlags = map[string]bool{
|
||||
"--token": true,
|
||||
"--client-secret": true,
|
||||
"--client-id": true,
|
||||
}
|
||||
|
||||
// SanitizeCommand redacts sensitive flag values from a command arg slice.
|
||||
func SanitizeCommand(args []string) string {
|
||||
sanitized := make([]string, 0, len(args))
|
||||
skipNext := false
|
||||
for _, arg := range args {
|
||||
if skipNext {
|
||||
sanitized = append(sanitized, "***")
|
||||
skipNext = false
|
||||
continue
|
||||
}
|
||||
if idx := strings.IndexByte(arg, '='); idx > 0 {
|
||||
key := arg[:idx]
|
||||
if sensitiveFlags[key] {
|
||||
sanitized = append(sanitized, key+"=***")
|
||||
continue
|
||||
}
|
||||
}
|
||||
if sensitiveFlags[arg] {
|
||||
skipNext = true
|
||||
}
|
||||
sanitized = append(sanitized, arg)
|
||||
}
|
||||
return strings.Join(sanitized, " ")
|
||||
}
|
||||
|
||||
+292
-12
@@ -16,7 +16,9 @@ package app
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -87,8 +89,8 @@ func TestTimingCollector_Print(t *testing.T) {
|
||||
tc.Print(&buf)
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "[Timing]") {
|
||||
t.Error("output should contain [Timing] header")
|
||||
if !strings.Contains(output, "[Perf]") {
|
||||
t.Error("output should contain [Perf] header")
|
||||
}
|
||||
if !strings.Contains(output, "auth_token") {
|
||||
t.Error("output should contain 'auth_token'")
|
||||
@@ -103,8 +105,8 @@ func TestTimingCollector_Print(t *testing.T) {
|
||||
|
||||
func TestTimingCollector_PrintIfEnabled(t *testing.T) {
|
||||
// Set environment variable
|
||||
os.Setenv(PerfTimingEnv, "1")
|
||||
defer os.Unsetenv(PerfTimingEnv)
|
||||
os.Setenv(PerfDebugEnv, "1")
|
||||
defer os.Unsetenv(PerfDebugEnv)
|
||||
|
||||
tc := NewTimingCollector()
|
||||
tc.Record("test_op", 10*time.Millisecond)
|
||||
@@ -136,6 +138,7 @@ func TestTimingCollector_ContextIntegration(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTimingCollectorFromContext_NilContext(t *testing.T) {
|
||||
//lint:ignore SA1012 Testing explicit nil-context guard in TimingCollectorFromContext.
|
||||
tc := TimingCollectorFromContext(nil)
|
||||
if tc != nil {
|
||||
t.Error("TimingCollectorFromContext(nil) should return nil")
|
||||
@@ -156,18 +159,295 @@ func TestStartTiming_NoCollector(t *testing.T) {
|
||||
stop()
|
||||
}
|
||||
|
||||
func TestIsPerfTimingEnabled(t *testing.T) {
|
||||
func TestIsPerfDebugEnabled(t *testing.T) {
|
||||
// Clear the env var first
|
||||
os.Unsetenv(PerfTimingEnv)
|
||||
os.Unsetenv(PerfDebugEnv)
|
||||
|
||||
if IsPerfTimingEnabled() {
|
||||
t.Error("IsPerfTimingEnabled should return false when env var is not set")
|
||||
if IsPerfDebugEnabled() {
|
||||
t.Error("IsPerfDebugEnabled should return false when env var is not set")
|
||||
}
|
||||
|
||||
os.Setenv(PerfTimingEnv, "1")
|
||||
defer os.Unsetenv(PerfTimingEnv)
|
||||
os.Setenv(PerfDebugEnv, "1")
|
||||
defer os.Unsetenv(PerfDebugEnv)
|
||||
|
||||
if !IsPerfTimingEnabled() {
|
||||
t.Error("IsPerfTimingEnabled should return true when env var is set")
|
||||
if !IsPerfDebugEnabled() {
|
||||
t.Error("IsPerfDebugEnabled should return true when env var is set")
|
||||
}
|
||||
}
|
||||
|
||||
// ── PerfReport tests ────────────────────────────────────────────────────
|
||||
|
||||
func TestBuildReport(t *testing.T) {
|
||||
tc := NewTimingCollector()
|
||||
tc.Record("cmd_init", 45*time.Millisecond)
|
||||
tc.Record("auth_keychain", 72*time.Millisecond)
|
||||
tc.Record("mcp_call", 620*time.Millisecond)
|
||||
|
||||
report := tc.BuildReport("v1.0.8", "dws aitable list-records")
|
||||
|
||||
if report.Kind != "perf_report" {
|
||||
t.Errorf("expected kind 'perf_report', got %q", report.Kind)
|
||||
}
|
||||
if report.Version != "1" {
|
||||
t.Errorf("expected version '1', got %q", report.Version)
|
||||
}
|
||||
if report.CLIVersion != "v1.0.8" {
|
||||
t.Errorf("expected cli_version 'v1.0.8', got %q", report.CLIVersion)
|
||||
}
|
||||
if report.Command != "dws aitable list-records" {
|
||||
t.Errorf("expected command 'dws aitable list-records', got %q", report.Command)
|
||||
}
|
||||
if len(report.Phases) != 3 {
|
||||
t.Fatalf("expected 3 phases, got %d", len(report.Phases))
|
||||
}
|
||||
if report.Phases[0].Name != "cmd_init" || report.Phases[0].DurationMs != 45 {
|
||||
t.Errorf("unexpected first phase: %+v", report.Phases[0])
|
||||
}
|
||||
if report.Slowest != "mcp_call" {
|
||||
t.Errorf("expected slowest 'mcp_call', got %q", report.Slowest)
|
||||
}
|
||||
if report.TotalMs < 0 {
|
||||
t.Errorf("total_ms should be >= 0, got %d", report.TotalMs)
|
||||
}
|
||||
if report.OverheadMs < 0 {
|
||||
t.Errorf("overhead_ms should be >= 0, got %d", report.OverheadMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReportEmpty(t *testing.T) {
|
||||
tc := NewTimingCollector()
|
||||
report := tc.BuildReport("dev", "dws version")
|
||||
|
||||
if len(report.Phases) != 0 {
|
||||
t.Errorf("expected 0 phases, got %d", len(report.Phases))
|
||||
}
|
||||
if report.Slowest != "" {
|
||||
t.Errorf("expected empty slowest, got %q", report.Slowest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReportJSON(t *testing.T) {
|
||||
tc := NewTimingCollector()
|
||||
tc.Record("cmd_init", 10*time.Millisecond)
|
||||
|
||||
report := tc.BuildReport("v1.0.0", "dws version")
|
||||
data, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("json.Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
requiredKeys := []string{"kind", "version", "cli_version", "command", "timestamp", "total_ms", "phases", "slowest", "overhead_ms"}
|
||||
for _, key := range requiredKeys {
|
||||
if _, ok := parsed[key]; !ok {
|
||||
t.Errorf("missing key %q in JSON output", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReportIfEnabled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reportPath := filepath.Join(dir, "report.json")
|
||||
|
||||
t.Setenv(PerfReportEnv, reportPath)
|
||||
|
||||
tc := NewTimingCollector()
|
||||
tc.Record("cmd_init", 50*time.Millisecond)
|
||||
tc.Record("mcp_call", 200*time.Millisecond)
|
||||
|
||||
tc.WriteReportIfEnabled("v1.0.0", "dws version")
|
||||
|
||||
data, err := os.ReadFile(reportPath)
|
||||
if err != nil {
|
||||
t.Fatalf("report file not written: %v", err)
|
||||
}
|
||||
|
||||
var report PerfReport
|
||||
if err := json.Unmarshal(data, &report); err != nil {
|
||||
t.Fatalf("invalid JSON in report: %v", err)
|
||||
}
|
||||
if report.Kind != "perf_report" {
|
||||
t.Errorf("expected kind 'perf_report', got %q", report.Kind)
|
||||
}
|
||||
if len(report.Phases) != 2 {
|
||||
t.Errorf("expected 2 phases, got %d", len(report.Phases))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReportIfEnabled_Auto(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
expected := filepath.Join(tmpHome, ".dws", "perf", "latest.json")
|
||||
|
||||
// Temporarily override HOME for defaultPerfReportPath
|
||||
t.Setenv("HOME", tmpHome)
|
||||
t.Setenv(PerfReportEnv, "auto")
|
||||
|
||||
tc := NewTimingCollector()
|
||||
tc.Record("cmd_init", 10*time.Millisecond)
|
||||
tc.WriteReportIfEnabled("v1.0.0", "dws version")
|
||||
|
||||
if _, err := os.Stat(expected); err != nil {
|
||||
t.Fatalf("expected report at %s: %v", expected, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReportIfEnabled_Disabled(t *testing.T) {
|
||||
t.Setenv(PerfReportEnv, "")
|
||||
|
||||
tc := NewTimingCollector()
|
||||
tc.Record("op", 10*time.Millisecond)
|
||||
tc.WriteReportIfEnabled("v1.0.0", "dws version")
|
||||
// No file should be written; no error expected
|
||||
}
|
||||
|
||||
func TestWriteReportIfEnabled_NilCollector(t *testing.T) {
|
||||
t.Setenv(PerfReportEnv, "/tmp/should-not-exist.json")
|
||||
var tc *TimingCollector
|
||||
tc.WriteReportIfEnabled("v1.0.0", "dws version")
|
||||
}
|
||||
|
||||
func TestLoadLatestReport(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
|
||||
perfDir := filepath.Join(tmpHome, ".dws", "perf")
|
||||
if err := os.MkdirAll(perfDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report := PerfReport{
|
||||
Kind: "perf_report",
|
||||
Version: "1",
|
||||
CLIVersion: "v1.0.0",
|
||||
Command: "dws version",
|
||||
TotalMs: 100,
|
||||
Phases: []PerfPhase{{Name: "cmd_init", DurationMs: 50, Seq: 0}},
|
||||
Slowest: "cmd_init",
|
||||
OverheadMs: 50,
|
||||
}
|
||||
data, _ := json.MarshalIndent(report, "", " ")
|
||||
if err := os.WriteFile(filepath.Join(perfDir, "latest.json"), data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := LoadLatestReport()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLatestReport failed: %v", err)
|
||||
}
|
||||
if loaded.CLIVersion != "v1.0.0" {
|
||||
t.Errorf("expected cli_version 'v1.0.0', got %q", loaded.CLIVersion)
|
||||
}
|
||||
if len(loaded.Phases) != 1 {
|
||||
t.Errorf("expected 1 phase, got %d", len(loaded.Phases))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLatestReport_NotFound(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
|
||||
_, err := LoadLatestReport()
|
||||
if err == nil {
|
||||
t.Error("expected error when report file does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no sensitive flags",
|
||||
args: []string{"dws", "aitable", "list-records"},
|
||||
want: "dws aitable list-records",
|
||||
},
|
||||
{
|
||||
name: "token with space-separated value",
|
||||
args: []string{"dws", "--token", "secret123", "version"},
|
||||
want: "dws --token *** version",
|
||||
},
|
||||
{
|
||||
name: "token with equals sign",
|
||||
args: []string{"dws", "--token=secret123", "version"},
|
||||
want: "dws --token=*** version",
|
||||
},
|
||||
{
|
||||
name: "client-secret space-separated",
|
||||
args: []string{"dws", "--client-secret", "mysecret", "--client-id", "myid", "auth"},
|
||||
want: "dws --client-secret *** --client-id *** auth",
|
||||
},
|
||||
{
|
||||
name: "client-id with equals",
|
||||
args: []string{"dws", "--client-id=abc123"},
|
||||
want: "dws --client-id=***",
|
||||
},
|
||||
{
|
||||
name: "empty args",
|
||||
args: []string{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := SanitizeCommand(tt.args)
|
||||
if got != tt.want {
|
||||
t.Errorf("SanitizeCommand(%v) = %q, want %q", tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePerfReportPath_Auto(t *testing.T) {
|
||||
p := resolvePerfReportPath("auto")
|
||||
if p == "" {
|
||||
t.Skip("HOME not available")
|
||||
}
|
||||
if !strings.HasSuffix(p, filepath.Join("perf", "latest.json")) {
|
||||
t.Errorf("expected path ending in perf/latest.json, got %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePerfReportPath_Custom(t *testing.T) {
|
||||
p := resolvePerfReportPath("/tmp/my-report.json")
|
||||
if p != "/tmp/my-report.json" {
|
||||
t.Errorf("expected '/tmp/my-report.json', got %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintPerfReportSummary(t *testing.T) {
|
||||
report := &PerfReport{
|
||||
Command: "dws version",
|
||||
Timestamp: time.Now(),
|
||||
TotalMs: 300,
|
||||
Phases: []PerfPhase{{Name: "cmd_init", DurationMs: 50, Seq: 0}, {Name: "mcp_call", DurationMs: 200, Seq: 1}},
|
||||
Slowest: "mcp_call",
|
||||
OverheadMs: 50,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
printPerfReportSummary(&buf, report)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "cmd_init") {
|
||||
t.Error("output should contain 'cmd_init'")
|
||||
}
|
||||
if !strings.Contains(out, "mcp_call") {
|
||||
t.Error("output should contain 'mcp_call'")
|
||||
}
|
||||
if !strings.Contains(out, "← 最慢") {
|
||||
t.Error("output should contain '← 最慢' marker")
|
||||
}
|
||||
if !strings.Contains(out, "总耗时") {
|
||||
t.Error("output should contain '总耗时'")
|
||||
}
|
||||
if !strings.Contains(out, "框架开销") {
|
||||
t.Error("output should contain '框架开销'")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ 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/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{}
|
||||
}
|
||||
|
||||
|
||||
+98
-14
@@ -10,10 +10,12 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -28,6 +30,8 @@ var (
|
||||
ugBoldGrn = color.New(color.Bold, color.FgGreen).SprintFunc()
|
||||
)
|
||||
|
||||
const defaultListLimit = 10
|
||||
|
||||
func newUpgradeCommand() *cobra.Command {
|
||||
var (
|
||||
flagCheck bool
|
||||
@@ -36,6 +40,7 @@ func newUpgradeCommand() *cobra.Command {
|
||||
flagRollback bool
|
||||
flagForce bool
|
||||
flagSkipSkills bool
|
||||
flagAll bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -47,17 +52,30 @@ func newUpgradeCommand() *cobra.Command {
|
||||
升级前会自动备份当前版本,可通过 --rollback 回滚。`,
|
||||
Example: ` dws upgrade # 交互式升级到最新版本
|
||||
dws upgrade --check # 仅检查是否有新版本
|
||||
dws upgrade --list # 列出所有可用版本
|
||||
dws upgrade --list # 列出最近版本
|
||||
dws upgrade --list --all # 列出所有版本
|
||||
dws upgrade --version v1.0.5 # 升级到指定版本
|
||||
dws upgrade --rollback # 回滚到上一版本
|
||||
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")
|
||||
format := resolveUpgradeFormat(cmd)
|
||||
|
||||
if flagList {
|
||||
return runUpgradeList(cmd, format)
|
||||
limit := defaultListLimit
|
||||
if flagAll {
|
||||
limit = 0
|
||||
}
|
||||
return runUpgradeList(cmd, format, limit)
|
||||
}
|
||||
if flagRollback {
|
||||
return runUpgradeRollback(yes)
|
||||
@@ -75,7 +93,8 @@ func newUpgradeCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&flagCheck, "check", false, "仅检查是否有新版本")
|
||||
cmd.Flags().BoolVar(&flagList, "list", false, "列出所有可用版本")
|
||||
cmd.Flags().BoolVar(&flagList, "list", false, "列出可用版本")
|
||||
cmd.Flags().BoolVar(&flagAll, "all", false, "与 --list 搭配,显示所有版本")
|
||||
cmd.Flags().StringVar(&flagVersion, "version", "", "升级到指定版本")
|
||||
cmd.Flags().BoolVar(&flagRollback, "rollback", false, "回滚到上一版本")
|
||||
cmd.Flags().BoolVar(&flagForce, "force", false, "强制重新安装当前版本")
|
||||
@@ -146,7 +165,9 @@ func runUpgradeCheck(cmd *cobra.Command, format string) error {
|
||||
|
||||
// --- dws upgrade --list ---
|
||||
|
||||
func runUpgradeList(cmd *cobra.Command, format string) error {
|
||||
// runUpgradeList displays available versions. When limit > 0, only the most
|
||||
// recent `limit` versions are shown; pass 0 to show all (--all flag).
|
||||
func runUpgradeList(cmd *cobra.Command, format string, limit int) error {
|
||||
client := upgrade.NewClient()
|
||||
|
||||
if format != "json" {
|
||||
@@ -158,6 +179,13 @@ func runUpgradeList(cmd *cobra.Command, format string) error {
|
||||
return fmt.Errorf("获取版本列表失败: %w", err)
|
||||
}
|
||||
|
||||
totalCount := len(versions)
|
||||
truncated := false
|
||||
if limit > 0 && len(versions) > limit {
|
||||
versions = versions[:limit]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
currentVer := strings.TrimPrefix(version, "v")
|
||||
|
||||
if format == "json" {
|
||||
@@ -171,13 +199,19 @@ func runUpgradeList(cmd *cobra.Command, format string) error {
|
||||
"changelog": parseChangelogEntries(v.Changelog, 10),
|
||||
})
|
||||
}
|
||||
return writeJSON(cmd.OutOrStdout(), map[string]any{
|
||||
result := map[string]any{
|
||||
"current_version": ensureV(version),
|
||||
"versions": items,
|
||||
})
|
||||
"total": totalCount,
|
||||
}
|
||||
if truncated {
|
||||
result["truncated"] = true
|
||||
result["shown"] = limit
|
||||
}
|
||||
return writeJSON(cmd.OutOrStdout(), result)
|
||||
}
|
||||
|
||||
if len(versions) == 0 {
|
||||
if totalCount == 0 {
|
||||
fmt.Printf(" %s\n", ugYellow("未找到任何版本"))
|
||||
return nil
|
||||
}
|
||||
@@ -203,7 +237,10 @@ func runUpgradeList(cmd *cobra.Command, format string) error {
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s %s\n", ugBold("当前版本:"), ugBoldGrn(ensureV(version)))
|
||||
fmt.Printf(" %s\n", ugDim("提示: 使用 dws upgrade --version v1.0.5 安装指定版本"))
|
||||
if truncated {
|
||||
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("显示最近 %d 个版本(共 %d 个),使用 --list --all 查看全部", limit, totalCount)))
|
||||
}
|
||||
fmt.Printf(" %s\n", ugDim("提示: 使用 dws upgrade --version v1.0.7 安装指定版本"))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -481,6 +518,16 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
fmt.Printf(" %s\n", ugGreen("✓"))
|
||||
}
|
||||
|
||||
// Clear discovery-derived caches so the upgraded binary rebuilds its
|
||||
// command tree from a fresh fetch instead of inheriting snapshots written
|
||||
// by the old version — a poisoned snapshot used to lock out every
|
||||
// invocation before the build guards landed (#447 / #449).
|
||||
if purged, purgeErr := cacheStoreFromEnv().PurgeDiscoveryData(); purgeErr != nil {
|
||||
fmt.Printf(" %s %s\n", ugYellow("⚠"), ugDim(fmt.Sprintf("清理发现缓存失败 (可手动运行 dws cache refresh): %v", purgeErr)))
|
||||
} else if len(purged) > 0 {
|
||||
fmt.Printf(" %s %s\n", ugGreen("✓"), ugDim("发现缓存已清空, 新版本首次运行时自动重建"))
|
||||
}
|
||||
|
||||
// Cleanup old backups
|
||||
rm.Cleanup(5)
|
||||
|
||||
@@ -544,13 +591,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) {
|
||||
@@ -560,6 +612,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_BlockedInEmbeddedMode(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)
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,11 @@ import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -428,3 +431,80 @@ func TestNewUpgradeCommand_Help(t *testing.T) {
|
||||
t.Error("help should contain --rollback")
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// AgentCodeEnv is the sole per-spawn environment variable the host injects
|
||||
// to declare "this process is driven by a third-party Agent host, render
|
||||
// authorization UI yourselves".
|
||||
AgentCodeEnv = "DINGTALK_DWS_AGENTCODE"
|
||||
)
|
||||
|
||||
// HostOwnsPATFlow reports whether the current process is running under a
|
||||
// third-party Agent host that will render the PAT authorization card
|
||||
// itself. The sole trigger is AgentCodeEnv (DINGTALK_DWS_AGENTCODE) being
|
||||
// non-empty. The CLI deliberately does not consult any other signal
|
||||
// (DINGTALK_AGENT / DWS_CHANNEL / the wire claw-type header) for this
|
||||
// decision so that server-side routing tags and the host-owned UI contract
|
||||
// remain independent concerns.
|
||||
func HostOwnsPATFlow() bool {
|
||||
return strings.TrimSpace(os.Getenv(AgentCodeEnv)) != ""
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestClassifyDenialReason(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status *CLIAuthStatus
|
||||
currentChannel string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "error CHANNEL_REQUIRED",
|
||||
status: &CLIAuthStatus{
|
||||
ErrorCode: "CHANNEL_REQUIRED",
|
||||
},
|
||||
want: "channel_required",
|
||||
},
|
||||
{
|
||||
name: "error NO_AUTH",
|
||||
status: &CLIAuthStatus{
|
||||
ErrorCode: "NO_AUTH",
|
||||
},
|
||||
want: "no_auth",
|
||||
},
|
||||
{
|
||||
name: "success false or nil result → unknown",
|
||||
status: &CLIAuthStatus{
|
||||
Success: false,
|
||||
},
|
||||
want: "unknown",
|
||||
},
|
||||
{
|
||||
name: "cliAuthEnabled true → no denial",
|
||||
status: &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: true},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "userScope forbidden wins over channel",
|
||||
status: &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{
|
||||
CLIAuthEnabled: false,
|
||||
UserScope: "forbidden",
|
||||
ChannelScope: "specified",
|
||||
AllowedChannels: []string{"channel-a"},
|
||||
},
|
||||
},
|
||||
currentChannel: "channel-b",
|
||||
want: "user_forbidden",
|
||||
},
|
||||
{
|
||||
// Real-world case reported: user is in allowedUsers but the current
|
||||
// DWS_CHANNEL is not in allowedChannels. Reason must be channel,
|
||||
// NOT user.
|
||||
name: "user allowed but channel not in allowedChannels → channel_not_allowed",
|
||||
status: &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{
|
||||
CLIAuthEnabled: false,
|
||||
UserScope: "specified",
|
||||
AllowedUsers: []string{"014566033934857460"},
|
||||
ChannelScope: "specified",
|
||||
AllowedChannels: []string{"2a4a658e467998befb7fa333c19ba2b3a3bacfa4"},
|
||||
},
|
||||
},
|
||||
currentChannel: "different-channel",
|
||||
want: "channel_not_allowed",
|
||||
},
|
||||
{
|
||||
name: "channelScope specified but current channel empty → channel_required",
|
||||
status: &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{
|
||||
CLIAuthEnabled: false,
|
||||
UserScope: "specified",
|
||||
ChannelScope: "specified",
|
||||
AllowedChannels: []string{"channel-a"},
|
||||
},
|
||||
},
|
||||
currentChannel: "",
|
||||
want: "channel_required",
|
||||
},
|
||||
{
|
||||
name: "channel matches allowedChannels → fall back to user denial",
|
||||
status: &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{
|
||||
CLIAuthEnabled: false,
|
||||
UserScope: "specified",
|
||||
ChannelScope: "specified",
|
||||
AllowedChannels: []string{"channel-a"},
|
||||
},
|
||||
},
|
||||
currentChannel: "channel-a",
|
||||
want: "user_not_allowed",
|
||||
},
|
||||
{
|
||||
name: "only userScope=specified, no channel restriction → user_not_allowed",
|
||||
status: &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{
|
||||
CLIAuthEnabled: false,
|
||||
UserScope: "specified",
|
||||
},
|
||||
},
|
||||
currentChannel: "",
|
||||
want: "user_not_allowed",
|
||||
},
|
||||
{
|
||||
name: "no user or channel restriction → cli_not_enabled",
|
||||
status: &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: false},
|
||||
},
|
||||
want: "cli_not_enabled",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := classifyDenialReason(tc.status, tc.currentChannel)
|
||||
if got != tc.want {
|
||||
t.Fatalf("classifyDenialReason() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -154,9 +154,7 @@ func TestCheckCLIAuthEnabled_TransientThenSuccess(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: true},
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: true},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
@@ -169,7 +167,7 @@ func TestCheckCLIAuthEnabled_TransientThenSuccess(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected success after transient failures, got error: %v", err)
|
||||
}
|
||||
if !status.Success || !status.Result.CLIAuthEnabled {
|
||||
if !status.Success || status.Result == nil || !status.Result.CLIAuthEnabled {
|
||||
t.Fatalf("expected CLIAuthEnabled=true, got %+v", status)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
@@ -190,9 +188,7 @@ func TestCheckCLIAuthEnabled_Enabled(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: true},
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: true},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
@@ -204,7 +200,7 @@ func TestCheckCLIAuthEnabled_Enabled(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !status.Result.CLIAuthEnabled {
|
||||
if status.Result == nil || !status.Result.CLIAuthEnabled {
|
||||
t.Fatal("expected CLIAuthEnabled=true")
|
||||
}
|
||||
t.Logf("✅ Normal enabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled)
|
||||
@@ -215,9 +211,7 @@ func TestCheckCLIAuthEnabled_Disabled(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: false},
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: false},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
@@ -229,7 +223,7 @@ func TestCheckCLIAuthEnabled_Disabled(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status.Result.CLIAuthEnabled {
|
||||
if status.Result == nil || status.Result.CLIAuthEnabled {
|
||||
t.Fatal("expected CLIAuthEnabled=false")
|
||||
}
|
||||
t.Logf("✅ Normal disabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled)
|
||||
@@ -262,12 +256,10 @@ func TestOAuthCallback_CLIAuthEnabled_ShowsSuccessPage(t *testing.T) {
|
||||
var statusErr error
|
||||
authStatus := &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: true},
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: true},
|
||||
}
|
||||
|
||||
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled
|
||||
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result != nil && authStatus.Result.CLIAuthEnabled
|
||||
if !cliAuthEnabled {
|
||||
t.Fatal("cliAuthEnabled should be true when API returns enabled")
|
||||
}
|
||||
@@ -280,12 +272,10 @@ func TestOAuthCallback_CLIAuthDisabledByServer_ShowsNotEnabledPage(t *testing.T)
|
||||
var statusErr error
|
||||
authStatus := &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: false},
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: false},
|
||||
}
|
||||
|
||||
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled
|
||||
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result != nil && authStatus.Result.CLIAuthEnabled
|
||||
if cliAuthEnabled {
|
||||
t.Fatal("cliAuthEnabled should be false when server says disabled")
|
||||
}
|
||||
@@ -314,12 +304,18 @@ func TestDeviceFlow_LoginOnce_CLIAuthError_FailClosed(t *testing.T) {
|
||||
VerificationURI: "https://example.com/verify",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
FlowID: "test-flow-id",
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, DeviceTokenPath):
|
||||
writeServiceResult(w, true, DeviceTokenResponse{
|
||||
AuthCode: "test-auth-code",
|
||||
}, "", "")
|
||||
case strings.HasSuffix(r.URL.Path, DevicePollPath):
|
||||
// New terminal API: return APPROVED status
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]string{
|
||||
"status": "APPROVED",
|
||||
"authCode": "test-auth-code",
|
||||
},
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
@@ -340,13 +336,14 @@ func TestDeviceFlow_LoginOnce_CLIAuthError_FailClosed(t *testing.T) {
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
provider := &DeviceFlowProvider{
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
terminalBaseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
_, err := provider.loginOnce(context.Background(), 1)
|
||||
@@ -378,12 +375,18 @@ func TestDeviceFlow_LoginOnce_CLIAuthDisabled_ShowsError(t *testing.T) {
|
||||
VerificationURI: "https://example.com/verify",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
FlowID: "test-flow-id",
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, DeviceTokenPath):
|
||||
writeServiceResult(w, true, DeviceTokenResponse{
|
||||
AuthCode: "test-auth-code",
|
||||
}, "", "")
|
||||
case strings.HasSuffix(r.URL.Path, DevicePollPath):
|
||||
// New terminal API: return APPROVED status
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]string{
|
||||
"status": "APPROVED",
|
||||
"authCode": "test-auth-code",
|
||||
},
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
@@ -396,9 +399,7 @@ func TestDeviceFlow_LoginOnce_CLIAuthDisabled_ShowsError(t *testing.T) {
|
||||
case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath):
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: false},
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: false},
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, SuperAdminPath):
|
||||
@@ -415,13 +416,14 @@ func TestDeviceFlow_LoginOnce_CLIAuthDisabled_ShowsError(t *testing.T) {
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
provider := &DeviceFlowProvider{
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
terminalBaseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
_, err := provider.loginOnce(context.Background(), 1)
|
||||
@@ -450,12 +452,18 @@ func TestDeviceFlow_LoginOnce_CLIAuthEnabled_Success(t *testing.T) {
|
||||
VerificationURI: "https://example.com/verify",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
FlowID: "test-flow-id",
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, DeviceTokenPath):
|
||||
writeServiceResult(w, true, DeviceTokenResponse{
|
||||
AuthCode: "test-auth-code",
|
||||
}, "", "")
|
||||
case strings.HasSuffix(r.URL.Path, DevicePollPath):
|
||||
// New terminal API: return APPROVED status
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]string{
|
||||
"status": "APPROVED",
|
||||
"authCode": "test-auth-code",
|
||||
},
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
@@ -468,9 +476,7 @@ func TestDeviceFlow_LoginOnce_CLIAuthEnabled_Success(t *testing.T) {
|
||||
case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath):
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: true},
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: true},
|
||||
})
|
||||
|
||||
default:
|
||||
@@ -481,13 +487,14 @@ func TestDeviceFlow_LoginOnce_CLIAuthEnabled_Success(t *testing.T) {
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
provider := &DeviceFlowProvider{
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
terminalBaseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
token, err := provider.loginOnce(context.Background(), 1)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+267
-55
@@ -33,32 +33,37 @@ import (
|
||||
|
||||
const (
|
||||
// defaultPollInterval is the default seconds between device token polls.
|
||||
defaultPollInterval = 5
|
||||
// The server-side Redis TTL is 10 minutes; a 2-second interval keeps the
|
||||
// user-perceived latency low while staying well within rate limits.
|
||||
defaultPollInterval = 2
|
||||
// maxPollInterval caps the polling interval to prevent DoS via slow_down.
|
||||
maxPollInterval = 30
|
||||
// maxPollTotalWait caps the total wait time for device authorization.
|
||||
maxPollTotalWait = 15 * time.Minute
|
||||
// Aligned with the server-side Redis TTL (10 minutes).
|
||||
maxPollTotalWait = 10 * time.Minute
|
||||
)
|
||||
|
||||
type DeviceFlowProvider struct {
|
||||
configDir string
|
||||
clientID string
|
||||
scope string
|
||||
baseURL string
|
||||
logger *slog.Logger
|
||||
Output io.Writer
|
||||
httpClient *http.Client
|
||||
configDir string
|
||||
clientID string
|
||||
scope string
|
||||
baseURL string
|
||||
terminalBaseURL string
|
||||
logger *slog.Logger
|
||||
Output io.Writer
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider {
|
||||
return &DeviceFlowProvider{
|
||||
configDir: configDir,
|
||||
clientID: ClientID(),
|
||||
scope: DefaultScopes,
|
||||
baseURL: DefaultDeviceBaseURL,
|
||||
logger: logger,
|
||||
Output: os.Stderr,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
configDir: configDir,
|
||||
clientID: ClientID(),
|
||||
scope: DefaultScopes,
|
||||
baseURL: DefaultDeviceBaseURL,
|
||||
terminalBaseURL: GetMCPBaseURL(),
|
||||
logger: logger,
|
||||
Output: os.Stderr,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +71,18 @@ func (p *DeviceFlowProvider) SetBaseURL(baseURL string) {
|
||||
p.baseURL = strings.TrimRight(baseURL, "/")
|
||||
}
|
||||
|
||||
// SetTerminalBaseURL sets the terminal API base URL for device flow polling.
|
||||
func (p *DeviceFlowProvider) SetTerminalBaseURL(baseURL string) {
|
||||
p.terminalBaseURL = strings.TrimRight(baseURL, "/")
|
||||
}
|
||||
|
||||
// SetScope overrides the OAuth scope for the device flow.
|
||||
func (p *DeviceFlowProvider) SetScope(scope string) {
|
||||
if p != nil {
|
||||
p.scope = scope
|
||||
}
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) output() io.Writer {
|
||||
if p != nil && p.Output != nil {
|
||||
return p.Output
|
||||
@@ -80,6 +97,7 @@ type DeviceAuthResponse struct {
|
||||
VerificationURIComplete string `json:"verificationUriComplete"`
|
||||
ExpiresIn int `json:"expiresIn"`
|
||||
Interval int `json:"interval"`
|
||||
FlowID string `json:"flowId"`
|
||||
}
|
||||
|
||||
type DeviceTokenResponse struct {
|
||||
@@ -88,6 +106,38 @@ type DeviceTokenResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// DevicePollResponse represents the response from the terminal API poll endpoint.
|
||||
type DevicePollResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data DevicePollData `json:"data"`
|
||||
// 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 {
|
||||
Status string `json:"status"`
|
||||
AuthCode string `json:"authCode,omitempty"`
|
||||
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"`
|
||||
@@ -95,22 +145,38 @@ type serviceResult struct {
|
||||
ErrorMsg string `json:"errorMsg"`
|
||||
}
|
||||
|
||||
// resetCredentialState clears any stale credential state inherited from
|
||||
// previous login methods (OAuth, PAT, etc.) so that device flow always
|
||||
// starts fresh by fetching clientID from MCP.
|
||||
//
|
||||
// This is a defensive measure: no matter what a prior login wrote to
|
||||
// app.json or runtime globals, 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).
|
||||
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 p.logger != nil {
|
||||
p.logger.Debug("client ID not configured, fetching from MCP server")
|
||||
}
|
||||
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)
|
||||
}
|
||||
// Defensive reset: clear any stale credential state from previous login
|
||||
// methods (OAuth scan, PAT, etc.) so we always re-fetch from MCP.
|
||||
// This ensures --device login works regardless of what app.json contains.
|
||||
p.resetCredentialState()
|
||||
|
||||
if p.logger != nil {
|
||||
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
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
|
||||
const maxAttempts = 3
|
||||
@@ -176,33 +242,61 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请检查网络连接后重试。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("检查 CLI 授权状态失败"), authErr)
|
||||
} else if authStatus.Success && !authStatus.Result.CLIAuthEnabled {
|
||||
// CLI auth is disabled - show detailed error with admin info
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织尚未开启 CLI 数据访问权限")))
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。"))
|
||||
}
|
||||
denialReason := classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL"))
|
||||
if denialReason != "" {
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
switch denialReason {
|
||||
case "user_forbidden":
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织已禁止所有成员使用 CLI")))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("该组织已禁止所有成员使用 CLI"))
|
||||
case "user_not_allowed":
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 您不在该组织的 CLI 授权人员范围内")))
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织管理员将您加入 CLI 授权人员名单。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("您不在该组织的 CLI 授权人员范围内,请联系组织管理员"))
|
||||
case "channel_not_allowed":
|
||||
ch := os.Getenv("DWS_CHANNEL")
|
||||
_, _ = fmt.Fprintf(p.output(), dfRed(i18n.T("⚠️ 当前渠道 %s 未获得该组织授权"))+"\n", ch)
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织管理员开通该渠道的访问权限。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, fmt.Errorf(i18n.T("当前渠道 %s 未获得该组织授权,请联系组织管理员"), ch)
|
||||
case "channel_required":
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 当前组织已开启渠道管控")))
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请升级到最新版本的 CLI 后重试。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("当前组织已开启渠道管控,请升级到最新版本的 CLI 后重试"))
|
||||
case "no_auth":
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 认证已失效")))
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请执行 dws auth 重新登录。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("认证已失效,请执行 dws auth 重新登录"))
|
||||
default:
|
||||
// cli_not_enabled or unknown — show existing admin-apply flow
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织尚未开启 CLI 数据访问权限")))
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
|
||||
// Try to get super admin list
|
||||
admins, adminErr := GetSuperAdmins(ctx, tokenData.AccessToken)
|
||||
if adminErr == nil && admins.Success && len(admins.Result) > 0 {
|
||||
// Show up to 3 admins
|
||||
maxAdmins := 3
|
||||
if len(admins.Result) < maxAdmins {
|
||||
maxAdmins = len(admins.Result)
|
||||
admins, adminErr := GetSuperAdmins(ctx, tokenData.AccessToken)
|
||||
if adminErr == nil && admins.Success && len(admins.Result) > 0 {
|
||||
maxAdmins := 3
|
||||
if len(admins.Result) < maxAdmins {
|
||||
maxAdmins = len(admins.Result)
|
||||
}
|
||||
var adminNames []string
|
||||
for i := 0; i < maxAdmins; i++ {
|
||||
adminNames = append(adminNames, admins.Result[i].Name)
|
||||
}
|
||||
_, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("组织主管理员:"), strings.Join(adminNames, "、"))
|
||||
}
|
||||
var adminNames []string
|
||||
for i := 0; i < maxAdmins; i++ {
|
||||
adminNames = append(adminNames, admins.Result[i].Name)
|
||||
}
|
||||
_, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("组织主管理员:"), strings.Join(adminNames, "、"))
|
||||
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织主管理员开启后重新登录。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
_, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("管理员操作入口:"), config.GetDeveloperSettingsURL())
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("该组织尚未开启 CLI 数据访问权限,请联系管理员开启"))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织主管理员开启后重新登录。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("该组织尚未开启 CLI 数据访问权限,请联系管理员开启"))
|
||||
}
|
||||
|
||||
// Save token data with associated client ID for refresh
|
||||
@@ -211,9 +305,19 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
|
||||
}
|
||||
|
||||
// Persist app credentials if using custom client credentials
|
||||
// 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)
|
||||
if !HasAppConfig(p.configDir) {
|
||||
_ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
|
||||
}
|
||||
}
|
||||
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
@@ -279,7 +383,92 @@ func (p *DeviceFlowProvider) pollDeviceToken(ctx context.Context, deviceCode str
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// pollDeviceStatus polls the terminal API for device authorization status.
|
||||
//
|
||||
// Note: The server returns success=false for REJECTED and EXPIRED terminal
|
||||
// states (with a valid data.Status value). These are normal business outcomes,
|
||||
// not transport errors, so we return the response to the caller and let the
|
||||
// status-switch handle them.
|
||||
func (p *DeviceFlowProvider) pollDeviceStatus(ctx context.Context, flowID string) (*DevicePollResponse, error) {
|
||||
endpoint := fmt.Sprintf("%s%s?flowId=%s", p.terminalBaseURL, DevicePollPath, url.QueryEscape(flowID))
|
||||
body, err := p.doGet(ctx, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp DevicePollResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("解析响应失败"), err)
|
||||
}
|
||||
// REJECTED/EXPIRED carry success=false but have a valid data.Status;
|
||||
// only treat as a real server error when data.Status is empty.
|
||||
if !resp.Success && resp.Data.Status == "" {
|
||||
return nil, fmt.Errorf("%s: [%s] %s", i18n.T("服务端返回错误"), resp.Code, resp.Message)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) waitForAuthorization(ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) {
|
||||
if auth.FlowID == "" {
|
||||
// Keep the pre-flowId device-code polling path for regular device flow
|
||||
// login responses that do not include terminal polling metadata.
|
||||
return p.waitForAuthorizationByDeviceCode(ctx, auth)
|
||||
}
|
||||
return p.waitForAuthorizationByFlowID(ctx, auth)
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) waitForAuthorizationByFlowID(ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) {
|
||||
startTime := time.Now()
|
||||
interval := time.Duration(auth.Interval) * time.Second
|
||||
deadline := time.Duration(auth.ExpiresIn) * time.Second
|
||||
pollCount := 0
|
||||
|
||||
for {
|
||||
elapsed := time.Since(startTime)
|
||||
if elapsed >= maxPollTotalWait || elapsed >= deadline {
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, fmt.Errorf("%s", i18n.Tf("设备授权码已过期(%d 秒),请重试", auth.ExpiresIn))
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(interval):
|
||||
}
|
||||
|
||||
pollCount++
|
||||
elapsedSec := int(time.Since(startTime).Seconds())
|
||||
dfPrintPollStatus(p.output(), pollCount, elapsedSec)
|
||||
|
||||
pollResp, err := p.pollDeviceStatus(ctx, auth.FlowID)
|
||||
if err != nil {
|
||||
dfPrintPollResult(p.output(), "network_error", i18n.T("网络错误,继续重试..."))
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("poll error", "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
pollData := pollResp.EffectiveData()
|
||||
switch pollData.Status {
|
||||
case StatusApproved:
|
||||
dfPrintPollResult(p.output(), "authorized", i18n.T("授权成功!"))
|
||||
return &DeviceTokenResponse{AuthCode: pollData.AuthCode}, nil
|
||||
case StatusPending:
|
||||
dfPrintPollResult(p.output(), "pending", i18n.T("等待用户授权..."))
|
||||
case StatusRejected:
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("用户拒绝了授权请求"))
|
||||
case StatusExpired:
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("设备授权码已过期"))
|
||||
default:
|
||||
dfPrintPollResult(p.output(), "unknown", fmt.Sprintf(i18n.T("未知状态: %s"), pollData.Status))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) waitForAuthorizationByDeviceCode(ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) {
|
||||
startTime := time.Now()
|
||||
interval := time.Duration(auth.Interval) * time.Second
|
||||
deadline := time.Duration(auth.ExpiresIn) * time.Second
|
||||
@@ -359,6 +548,29 @@ func (p *DeviceFlowProvider) postForm(ctx context.Context, endpoint string, para
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// doGet performs an HTTP GET request and returns the response body.
|
||||
func (p *DeviceFlowProvider) doGet(ctx context.Context, endpoint string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("创建请求失败"), err)
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("发送请求失败"), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("读取响应失败"), err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncateBody(body, 200))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// truncateBody returns a string of at most maxLen bytes from body, appending
|
||||
// "...(truncated)" when the content exceeds the limit. This prevents leaking
|
||||
// potentially sensitive response payloads in error messages.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
// Device flow authorization status constants.
|
||||
// Shared across device_flow.go and pat_auth_retry.go to avoid maintaining
|
||||
// string literals in multiple places.
|
||||
const (
|
||||
StatusPending = "PENDING"
|
||||
StatusApproved = "APPROVED"
|
||||
StatusRejected = "REJECTED"
|
||||
StatusExpired = "EXPIRED"
|
||||
StatusCancelled = "CANCELLED"
|
||||
)
|
||||
|
||||
// ParseDeviceFlowStatus normalizes a raw status string from the device flow
|
||||
// poll response into a canonical status constant. When the server returns an
|
||||
// empty status with success=false, it falls back to StatusExpired (server
|
||||
// error / flow not found).
|
||||
func ParseDeviceFlowStatus(rawStatus string, success bool) string {
|
||||
switch rawStatus {
|
||||
case StatusApproved, StatusRejected, StatusExpired, StatusPending, StatusCancelled:
|
||||
return rawStatus
|
||||
default:
|
||||
if rawStatus == "" && !success {
|
||||
return StatusExpired
|
||||
}
|
||||
return rawStatus
|
||||
}
|
||||
}
|
||||
@@ -14,15 +14,21 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
)
|
||||
|
||||
func newDeviceFlowTestLogger() *slog.Logger {
|
||||
@@ -89,22 +95,42 @@ func TestWaitForAuthorizationSucceedsAfterPending(t *testing.T) {
|
||||
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// New terminal API uses GET method
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("method = %s, want GET", r.Method)
|
||||
}
|
||||
if !strings.Contains(r.URL.RawQuery, "flowId=") {
|
||||
t.Fatal("flowId query parameter should be present")
|
||||
}
|
||||
if calls.Add(1) <= 2 {
|
||||
writeServiceResult(w, true, DeviceTokenResponse{Error: "authorization_pending"}, "", "")
|
||||
// Return PENDING status
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]string{"status": "PENDING"},
|
||||
})
|
||||
return
|
||||
}
|
||||
writeServiceResult(w, true, DeviceTokenResponse{AuthCode: "final-auth-code"}, "", "")
|
||||
// Return APPROVED status with authCode
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]string{
|
||||
"status": "APPROVED",
|
||||
"authCode": "final-auth-code",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
|
||||
provider.Output = io.Discard
|
||||
provider.SetBaseURL(server.URL)
|
||||
provider.SetTerminalBaseURL(server.URL)
|
||||
|
||||
resp, err := provider.waitForAuthorization(context.Background(), &DeviceAuthResponse{
|
||||
DeviceCode: "dc-1",
|
||||
ExpiresIn: 10,
|
||||
Interval: 1,
|
||||
FlowID: "test-flow-id",
|
||||
ExpiresIn: 10,
|
||||
Interval: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForAuthorization() error = %v", err)
|
||||
@@ -117,9 +143,185 @@ 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()
|
||||
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", r.Method)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("ParseForm() error = %v", err)
|
||||
}
|
||||
if got := r.FormValue("device_code"); got != "legacy-device-code" {
|
||||
t.Fatalf("device_code = %q, want legacy-device-code", got)
|
||||
}
|
||||
if calls.Add(1) <= 2 {
|
||||
writeServiceResult(w, true, DeviceTokenResponse{Error: "authorization_pending"}, "", "")
|
||||
return
|
||||
}
|
||||
writeServiceResult(w, true, DeviceTokenResponse{AuthCode: "legacy-auth-code"}, "", "")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
|
||||
var output bytes.Buffer
|
||||
provider.Output = &output
|
||||
provider.SetBaseURL(server.URL)
|
||||
|
||||
resp, err := provider.waitForAuthorization(context.Background(), &DeviceAuthResponse{
|
||||
DeviceCode: "legacy-device-code",
|
||||
ExpiresIn: 10,
|
||||
Interval: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForAuthorization() error = %v", err)
|
||||
}
|
||||
if resp.AuthCode != "legacy-auth-code" {
|
||||
t.Fatalf("auth code = %q, want legacy-auth-code", resp.AuthCode)
|
||||
}
|
||||
if calls.Load() != 3 {
|
||||
t.Fatalf("poll calls = %d, want 3", calls.Load())
|
||||
}
|
||||
if !strings.Contains(output.String(), i18n.T("等待用户授权...")) {
|
||||
t.Fatalf("expected device-code path to emit pending output, got %q", output.String())
|
||||
}
|
||||
if !strings.Contains(output.String(), i18n.T("授权成功!")) {
|
||||
t.Fatalf("expected device-code path to emit success output, got %q", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAuthorizationHonorsContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// New terminal API uses GET method
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]string{"status": "PENDING"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
|
||||
provider.Output = io.Discard
|
||||
provider.SetTerminalBaseURL(server.URL)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if _, err := provider.waitForAuthorization(ctx, &DeviceAuthResponse{
|
||||
FlowID: "test-flow-id-2",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
}); err == nil {
|
||||
t.Fatal("waitForAuthorization() error = nil, want context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAuthorizationByDeviceCodeHonorsContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeServiceResult(w, true, DeviceTokenResponse{Error: "authorization_pending"}, "", "")
|
||||
}))
|
||||
@@ -132,11 +334,89 @@ func TestWaitForAuthorizationHonorsContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if _, err := provider.waitForAuthorization(ctx, &DeviceAuthResponse{
|
||||
DeviceCode: "dc-2",
|
||||
_, err := provider.waitForAuthorization(ctx, &DeviceAuthResponse{
|
||||
DeviceCode: "legacy-device-code",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
}); err == nil {
|
||||
t.Fatal("waitForAuthorization() error = nil, want context cancellation")
|
||||
})
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("waitForAuthorization() error = %v, want context deadline exceeded", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAuthorizationByDeviceCodeErrorStates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
timeout time.Duration
|
||||
responses []DeviceTokenResponse
|
||||
wantErr string
|
||||
wantErrIs error
|
||||
wantOutput string
|
||||
}{
|
||||
{
|
||||
name: "slow_down_then_context_cancelled",
|
||||
timeout: 1500 * time.Millisecond,
|
||||
responses: []DeviceTokenResponse{{Error: "slow_down"}},
|
||||
wantErrIs: context.DeadlineExceeded,
|
||||
wantOutput: fmt.Sprintf(i18n.T("轮询过快,间隔增加至 %ds"), 6),
|
||||
},
|
||||
{
|
||||
name: "access_denied",
|
||||
timeout: 5 * time.Second,
|
||||
responses: []DeviceTokenResponse{{Error: "access_denied"}},
|
||||
wantErr: i18n.T("用户拒绝了授权请求"),
|
||||
wantOutput: fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), 1, 1),
|
||||
},
|
||||
{
|
||||
name: "expired_token",
|
||||
timeout: 5 * time.Second,
|
||||
responses: []DeviceTokenResponse{{Error: "expired_token"}},
|
||||
wantErr: i18n.T("设备授权码已过期"),
|
||||
wantOutput: fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), 1, 1),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
idx := int(calls.Add(1)) - 1
|
||||
if idx >= len(tt.responses) {
|
||||
idx = len(tt.responses) - 1
|
||||
}
|
||||
writeServiceResult(w, true, tt.responses[idx], "", "")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
|
||||
var output bytes.Buffer
|
||||
provider.Output = &output
|
||||
provider.SetBaseURL(server.URL)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), tt.timeout)
|
||||
defer cancel()
|
||||
|
||||
_, err := provider.waitForAuthorization(ctx, &DeviceAuthResponse{
|
||||
DeviceCode: "legacy-device-code",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
})
|
||||
|
||||
if tt.wantErrIs != nil {
|
||||
if !errors.Is(err, tt.wantErrIs) {
|
||||
t.Fatalf("waitForAuthorization() error = %v, want %v", err, tt.wantErrIs)
|
||||
}
|
||||
} else if err == nil || err.Error() != tt.wantErr {
|
||||
t.Fatalf("waitForAuthorization() error = %v, want %q", err, tt.wantErr)
|
||||
}
|
||||
|
||||
if !strings.Contains(output.String(), tt.wantOutput) {
|
||||
t.Fatalf("expected output to contain %q, got %q", tt.wantOutput, output.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+59
-14
@@ -18,8 +18,34 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CLIENT_ID",
|
||||
Category: configmeta.CategoryAuth,
|
||||
Description: "OAuth AppKey (DingTalk 应用凭证)",
|
||||
DefaultValue: "(内置)",
|
||||
Sensitive: true,
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CLIENT_SECRET",
|
||||
Category: configmeta.CategoryAuth,
|
||||
Description: "OAuth AppSecret (DingTalk 应用凭证)",
|
||||
DefaultValue: "(内置)",
|
||||
Sensitive: true,
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CHANNEL",
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "第三方渠道编码 (channelCode),如 Qoderwork",
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
// AuthorizeURL is the DingTalk OAuth authorization page.
|
||||
AuthorizeURL = "https://login.dingtalk.com/oauth2/auth"
|
||||
@@ -58,11 +84,19 @@ const (
|
||||
// DeviceGrantType is the grant_type value defined by RFC 8628.
|
||||
DeviceGrantType = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
|
||||
// Terminal API base URL for developer settings page.
|
||||
DefaultTerminalBaseURL = "https://open-dev.dingtalk.com"
|
||||
// DevicePollPath is the device flow polling path (used with MCP base URL).
|
||||
DevicePollPath = "/cli/oauth/device/poll"
|
||||
|
||||
// DeveloperSettingsPath is the path to the organization developer settings page.
|
||||
DeveloperSettingsPath = "/fe/old#/developerSettings"
|
||||
|
||||
LogoutURL = "https://login.dingtalk.com/oauth2/logout"
|
||||
LogoutContinueURL = "https://login.dingtalk.com"
|
||||
|
||||
// MCP API endpoints for CLI authorization management.
|
||||
DefaultMCPBaseURL = "https://mcp.dingtalk.com"
|
||||
DefaultMCPBaseURL = config.DefaultMCPBaseURL
|
||||
CLIAuthEnabledPath = "/cli/cliAuthEnabled"
|
||||
SuperAdminPath = "/cli/superAdmin"
|
||||
SendCliAuthApplyPath = "/cli/sendCliAuthApply"
|
||||
@@ -72,19 +106,32 @@ 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:
|
||||
// 1. ~/.dws/terminal_url file content (for pre-release environment)
|
||||
// 2. Default value (https://open-dev.dingtalk.com)
|
||||
func GetTerminalBaseURL() string {
|
||||
return config.GetTerminalBaseURL()
|
||||
}
|
||||
|
||||
// GetDeveloperSettingsURL returns the full URL to the organization developer
|
||||
// settings page, derived from the terminal base URL.
|
||||
func GetDeveloperSettingsURL() string {
|
||||
return config.GetDeveloperSettingsURL()
|
||||
}
|
||||
|
||||
// GetMCPBaseURL returns the MCP base URL with priority:
|
||||
// 1. ~/.dws/mcp_url file content (for pre-release environment)
|
||||
// 2. Default value (https://mcp.dingtalk.com)
|
||||
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).
|
||||
@@ -110,7 +157,7 @@ func SetClientIDFromMCP(id string) {
|
||||
func IsClientIDFromMCP() bool {
|
||||
clientMu.RLock()
|
||||
defer clientMu.RUnlock()
|
||||
return clientIDFromMCP
|
||||
return clientIDFromMCP || edition.Get().AuthClientFromMCP
|
||||
}
|
||||
|
||||
// GetUserAccessTokenURL returns the appropriate token exchange URL.
|
||||
@@ -189,6 +236,9 @@ func ClientID() string {
|
||||
if override != "" {
|
||||
return override
|
||||
}
|
||||
if id := edition.Get().AuthClientID; id != "" {
|
||||
return id
|
||||
}
|
||||
// Try loading from persisted app config
|
||||
if id, _ := ResolveAppCredentials(getDefaultConfigDir()); id != "" {
|
||||
return id
|
||||
@@ -240,11 +290,6 @@ func getRuntimeCredentials() (clientID, clientSecret string) {
|
||||
return runtimeClientID, runtimeClientSecret
|
||||
}
|
||||
|
||||
// getEnvClientID returns the environment variable client ID if set.
|
||||
func getEnvClientID() string {
|
||||
return os.Getenv("DWS_CLIENT_ID")
|
||||
}
|
||||
|
||||
// getDefaultConfigDir returns the default configuration directory.
|
||||
// Priority: DWS_CONFIG_DIR env var > ~/.dws
|
||||
func getDefaultConfigDir() string {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
@@ -59,6 +61,19 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// ExchangeCodeForToken exchanges an authorization code for token data using
|
||||
// the currently configured client credentials. This is a convenience wrapper
|
||||
// around OAuthProvider.exchangeCode for callers outside the auth package.
|
||||
func ExchangeCodeForToken(ctx context.Context, configDir, code string) (*TokenData, error) {
|
||||
p := &OAuthProvider{
|
||||
configDir: configDir,
|
||||
clientID: ClientID(),
|
||||
Output: io.Discard,
|
||||
httpClient: oauthHTTPClient,
|
||||
}
|
||||
return p.exchangeCode(ctx, code)
|
||||
}
|
||||
|
||||
// exchangeCodeViaMCP exchanges auth code for token via MCP proxy.
|
||||
// This is used when client secret is not available (server-side secret management).
|
||||
func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*TokenData, error) {
|
||||
@@ -919,14 +934,254 @@ const notEnabledHTML = `<!doctype html>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const accessDeniedHTML = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>钉钉 CLI</title>
|
||||
<style>
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
.card {
|
||||
height: 600px;
|
||||
width: 480px;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #f2f2f6;
|
||||
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.12);
|
||||
padding: 32px 24px 24px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
.lock-icon {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
h1 {
|
||||
margin: 8px 0 0;
|
||||
font-family:
|
||||
"PingFang SC",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 44px;
|
||||
text-align: center;
|
||||
letter-spacing: normal;
|
||||
color: #181c1f;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-family:
|
||||
"PingFang SC",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
line-height: 21px;
|
||||
text-align: center;
|
||||
letter-spacing: normal;
|
||||
color: rgba(24, 28, 31, 0.6);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<img
|
||||
class="lock-icon"
|
||||
src="https://img.alicdn.com/imgextra/i4/O1CN01fS3xxz1vbzZSGjbe0_!!6000000006192-2-tps-480-480.png"
|
||||
alt="lock icon"
|
||||
/>
|
||||
<h1>无权限访问</h1>
|
||||
<p>您不在该组织的 CLI 授权人员范围内。请联系组织管理员将您加入授权名单。此页面可以关闭。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const channelDeniedHTML = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>钉钉 CLI</title>
|
||||
<style>
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
.card {
|
||||
height: 600px;
|
||||
width: 480px;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #f2f2f6;
|
||||
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.12);
|
||||
padding: 32px 24px 24px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
.lock-icon {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
h1 {
|
||||
margin: 8px 0 0;
|
||||
font-family:
|
||||
"PingFang SC",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 44px;
|
||||
text-align: center;
|
||||
letter-spacing: normal;
|
||||
color: #181c1f;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-family:
|
||||
"PingFang SC",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
line-height: 21px;
|
||||
text-align: center;
|
||||
letter-spacing: normal;
|
||||
color: rgba(24, 28, 31, 0.6);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<img
|
||||
class="lock-icon"
|
||||
src="https://img.alicdn.com/imgextra/i4/O1CN01fS3xxz1vbzZSGjbe0_!!6000000006192-2-tps-480-480.png"
|
||||
alt="lock icon"
|
||||
/>
|
||||
<h1>渠道未授权</h1>
|
||||
<p>当前渠道未获得该组织授权,或组织已开启渠道管控。请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI。此页面可以关闭。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// CLIAuthStatus represents the response from /cli/cliAuthEnabled API.
|
||||
type CLIAuthStatus struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
Result struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
} `json:"result"`
|
||||
Success bool `json:"success"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
Result *CLIAuthResult `json:"result"`
|
||||
}
|
||||
|
||||
// CLIAuthResult holds the business data returned by /cli/cliAuthEnabled.
|
||||
// The server computes cliAuthEnabled by considering the org switch, userScope,
|
||||
// and channelScope together; the CLI uses it as-is.
|
||||
type CLIAuthResult struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
UserScope string `json:"userScope,omitempty"` // "all" | "specified" | "forbidden"
|
||||
AllowedUsers []string `json:"allowedUsers,omitempty"` // staffId list when userScope="specified"
|
||||
ChannelScope string `json:"channelScope,omitempty"` // "all" | "specified"
|
||||
AllowedChannels []string `json:"allowedChannels,omitempty"` // channelCode list when channelScope="specified"
|
||||
ChannelConfigEnabled bool `json:"channelConfigEnabled,omitempty"` // whether org has any channel restriction configured
|
||||
}
|
||||
|
||||
// classifyDenialReason inspects a CLIAuthStatus response and returns a machine-readable
|
||||
// denial reason string. Returns "" when access is granted.
|
||||
//
|
||||
// Priority rationale:
|
||||
// 1. Explicit org-wide ban (userScope=forbidden) always wins.
|
||||
// 2. Channel scope is evaluated BEFORE user scope because the CLI has
|
||||
// authoritative knowledge of DWS_CHANNEL and can verify membership against
|
||||
// allowedChannels. This avoids falsely blaming the user when the real
|
||||
// denial cause is a channel mismatch (e.g. user is in allowedUsers but the
|
||||
// current channel is not in allowedChannels).
|
||||
// 3. Only when the channel is unrestricted or matches do we attribute the
|
||||
// denial to the user scope.
|
||||
func classifyDenialReason(status *CLIAuthStatus, currentChannel string) string {
|
||||
if status.ErrorCode == "CHANNEL_REQUIRED" {
|
||||
return "channel_required"
|
||||
}
|
||||
if status.ErrorCode == "NO_AUTH" {
|
||||
return "no_auth"
|
||||
}
|
||||
if status.Result == nil || !status.Success {
|
||||
return "unknown"
|
||||
}
|
||||
r := status.Result
|
||||
if r.CLIAuthEnabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
if r.UserScope == "forbidden" {
|
||||
return "user_forbidden"
|
||||
}
|
||||
|
||||
if r.ChannelScope == "specified" {
|
||||
if currentChannel == "" {
|
||||
return "channel_required"
|
||||
}
|
||||
if !slices.Contains(r.AllowedChannels, currentChannel) {
|
||||
return "channel_not_allowed"
|
||||
}
|
||||
}
|
||||
|
||||
if r.UserScope == "specified" {
|
||||
return "user_not_allowed"
|
||||
}
|
||||
return "cli_not_enabled"
|
||||
}
|
||||
|
||||
// SuperAdmin represents a corp super admin.
|
||||
@@ -985,6 +1240,9 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
if ch := os.Getenv("DWS_CHANNEL"); ch != "" {
|
||||
req.Header.Set("x-dws-channel", ch)
|
||||
}
|
||||
|
||||
client := p.httpClient
|
||||
if client == nil {
|
||||
|
||||
@@ -55,6 +55,16 @@ func NewOAuthProvider(configDir string, logger *slog.Logger) *OAuthProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// resetCredentialState clears any stale credential state inherited from
|
||||
// previous login methods so that OAuth flow always starts fresh by
|
||||
// fetching clientID from MCP.
|
||||
func (p *OAuthProvider) resetCredentialState() {
|
||||
p.clientID = ""
|
||||
clientMu.Lock()
|
||||
clientIDFromMCP = false
|
||||
clientMu.Unlock()
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) output() io.Writer {
|
||||
if p != nil && p.Output != nil {
|
||||
return p.Output
|
||||
@@ -75,6 +85,10 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("access_token still valid, skipping login")
|
||||
}
|
||||
// Even on early return, persist custom app credentials if provided
|
||||
// via --client-id/--client-secret flags. Without this, the flags
|
||||
// are only in runtime globals and lost when the process exits.
|
||||
p.persistAppConfigIfNeeded()
|
||||
return data, nil
|
||||
}
|
||||
// Case 2: refresh using refresh_token (with lock to prevent concurrent refresh).
|
||||
@@ -84,6 +98,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
refreshed, rErr := p.lockedRefresh(ctx)
|
||||
if rErr == nil {
|
||||
p.persistAppConfigIfNeeded()
|
||||
return refreshed, nil
|
||||
}
|
||||
if p.logger != nil {
|
||||
@@ -94,21 +109,22 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
|
||||
// Fall through: full browser OAuth flow.
|
||||
// Ensure we have a valid client ID (fetch from MCP if not available)
|
||||
if p.clientID == "" {
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("client ID not configured, fetching from MCP server")
|
||||
}
|
||||
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, so we use MCP OAuth endpoints
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
// Defensive reset: clear any stale credential state from previous login
|
||||
// methods so we always re-fetch clientID from MCP. This ensures
|
||||
// --force login works regardless of what app.json contains.
|
||||
p.resetCredentialState()
|
||||
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (OAuth 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
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
|
||||
// Find a free port for the callback server.
|
||||
@@ -124,6 +140,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
token *TokenData
|
||||
err error
|
||||
cliAuthDisabled bool
|
||||
denialReason string
|
||||
}
|
||||
resultCh := make(chan callbackResult, 1)
|
||||
errCh := make(chan error, 1)
|
||||
@@ -236,19 +253,30 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
|
||||
// Check CLI auth enabled status (fail-closed: treat errors as disabled)
|
||||
authStatus, statusErr := p.CheckCLIAuthEnabled(ctx, tokenData.AccessToken)
|
||||
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled
|
||||
var denialReason string
|
||||
if statusErr != nil {
|
||||
denialReason = "unknown"
|
||||
} else {
|
||||
denialReason = classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL"))
|
||||
}
|
||||
cliAuthEnabled := denialReason == ""
|
||||
|
||||
// Update CLI auth disabled state
|
||||
callbackTokenMu.Lock()
|
||||
callbackAuthDisabled = !cliAuthEnabled
|
||||
callbackTokenMu.Unlock()
|
||||
|
||||
// Display appropriate HTML based on CLI auth status
|
||||
// Display appropriate HTML based on auth status and denial reason
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if !cliAuthEnabled {
|
||||
_, _ = fmt.Fprint(w, notEnabledHTML)
|
||||
} else {
|
||||
switch {
|
||||
case cliAuthEnabled:
|
||||
_, _ = fmt.Fprint(w, successHTML)
|
||||
case denialReason == "user_forbidden" || denialReason == "user_not_allowed":
|
||||
_, _ = fmt.Fprint(w, accessDeniedHTML)
|
||||
case denialReason == "channel_not_allowed" || denialReason == "channel_required":
|
||||
_, _ = fmt.Fprint(w, channelDeniedHTML)
|
||||
default:
|
||||
_, _ = fmt.Fprint(w, notEnabledHTML)
|
||||
}
|
||||
// Ensure response is flushed to client
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
@@ -256,7 +284,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
// Notify main goroutine with full result
|
||||
select {
|
||||
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled}:
|
||||
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled, denialReason: denialReason}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
@@ -395,8 +423,18 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), result.err)
|
||||
}
|
||||
|
||||
// Handle CLI auth disabled - keep server running for user to apply
|
||||
// Handle CLI auth disabled - for terminal denial reasons, exit immediately
|
||||
// (page shows accessDeniedHTML/channelDeniedHTML with no apply button,
|
||||
// so polling for apply submission would hang forever).
|
||||
// Error messages are kept consistent with the text shown on the HTML pages.
|
||||
if result.cliAuthDisabled {
|
||||
switch result.denialReason {
|
||||
case "user_forbidden", "user_not_allowed":
|
||||
return nil, errors.New(i18n.T("您不在该组织的 CLI 授权人员范围内,请联系组织管理员将您加入授权名单"))
|
||||
case "channel_not_allowed", "channel_required":
|
||||
return nil, errors.New(i18n.T("当前渠道未获得该组织授权,或组织已开启渠道管控,请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI"))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T("⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请..."))
|
||||
|
||||
@@ -435,7 +473,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
// Check if CLI auth is now enabled (admin approved)
|
||||
if currentToken != nil {
|
||||
authStatus, err := p.CheckCLIAuthEnabled(ctx, currentToken.AccessToken)
|
||||
if err == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled {
|
||||
if err == nil && classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL")) == "" {
|
||||
_, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录..."))
|
||||
time.Sleep(2 * time.Second)
|
||||
result.token = currentToken
|
||||
@@ -463,9 +501,19 @@ continueLogin:
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
|
||||
}
|
||||
|
||||
// Persist app credentials if using custom client credentials
|
||||
// Persist app credentials (with secret) if using custom client credentials.
|
||||
// MUST run BEFORE os.Setenv below to avoid env-matching short circuit.
|
||||
p.persistAppConfigIfNeeded()
|
||||
|
||||
// Always persist clientId to app.json so future process startups
|
||||
// can load it via ResolveAppCredentials and populate DWS_CLIENT_ID env.
|
||||
if p.clientID != "" {
|
||||
_ = os.Setenv("DWS_CLIENT_ID", p.clientID)
|
||||
if !HasAppConfig(p.configDir) {
|
||||
_ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
|
||||
}
|
||||
}
|
||||
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
@@ -587,9 +635,8 @@ func (p *OAuthProvider) persistAppConfigIfNeeded() {
|
||||
return
|
||||
}
|
||||
|
||||
// Only persist if they differ from environment/default values
|
||||
envID := getEnvClientID()
|
||||
if clientID == envID || clientID == DefaultClientID {
|
||||
// Skip if using default placeholder credentials
|
||||
if clientID == DefaultClientID {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
// 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 (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
// PortableImportReport summarizes bundle metadata consumed during import.
|
||||
type PortableImportReport struct {
|
||||
BundleOS string
|
||||
OSMismatch bool
|
||||
}
|
||||
|
||||
// PortableExportSupported reports whether the current platform can produce a
|
||||
// bundle that includes the file-based DEK required for import elsewhere.
|
||||
func PortableExportSupported() bool {
|
||||
if runtime.GOOS != "darwin" {
|
||||
return true
|
||||
}
|
||||
return os.Getenv(keychain.DisableKeychainEnv) != ""
|
||||
}
|
||||
|
||||
// PortableAuthTargetPopulated reports whether local auth files would be
|
||||
// overwritten by a portable import.
|
||||
func PortableAuthTargetPopulated(configDir string) bool {
|
||||
if TokenDataExistsKeychain() {
|
||||
return true
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, "app.json")); err == nil {
|
||||
return true
|
||||
}
|
||||
encPath := filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc")
|
||||
if _, err := os.Stat(encPath); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PortableAuthSourceReady reports whether encrypted auth token exists for export.
|
||||
func PortableAuthSourceReady() bool {
|
||||
return portableAuthSourcePopulated(keychain.StorageDir(keychain.Service))
|
||||
}
|
||||
|
||||
func portableAuthSourcePopulated(keychainDir string) bool {
|
||||
_, err := os.Stat(filepath.Join(keychainDir, keychain.AccountToken+".enc"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
const portableAuthManifest = "manifest.json"
|
||||
|
||||
type portableAuthBundleManifest struct {
|
||||
Version int `json:"version"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
OS string `json:"os"`
|
||||
KeychainService string `json:"keychain_service"`
|
||||
ConfigFiles []string `json:"config_files,omitempty"`
|
||||
}
|
||||
|
||||
// ExportPortableAuthBundle writes a portable auth bundle as tar.gz.
|
||||
// It copies the encrypted keychain files plus the small config files needed
|
||||
// to refresh tokens in another Linux sandbox.
|
||||
func ExportPortableAuthBundle(configDir string, w io.Writer) error {
|
||||
if w == nil {
|
||||
return fmt.Errorf("missing output writer")
|
||||
}
|
||||
if !PortableExportSupported() {
|
||||
return fmt.Errorf("portable export unavailable on macOS while DEK is in system Keychain; set %s=1, re-login, then export", keychain.DisableKeychainEnv)
|
||||
}
|
||||
keychainDir := keychain.StorageDir(keychain.Service)
|
||||
if _, err := os.Stat(keychainDir); err != nil {
|
||||
return fmt.Errorf("auth keychain directory is not available: %w", err)
|
||||
}
|
||||
if !portableAuthSourcePopulated(keychainDir) {
|
||||
return fmt.Errorf("auth token is not available for export; run dws auth login first")
|
||||
}
|
||||
|
||||
gz := gzip.NewWriter(w)
|
||||
defer gz.Close()
|
||||
tw := tar.NewWriter(gz)
|
||||
defer tw.Close()
|
||||
|
||||
configFiles, err := portableConfigFiles(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manifest := portableAuthBundleManifest{
|
||||
Version: 1,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
OS: runtime.GOOS,
|
||||
KeychainService: keychain.Service,
|
||||
ConfigFiles: configFiles,
|
||||
}
|
||||
if err := writePortableManifest(tw, manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := addPortableDir(tw, keychainDir, path.Join("keychain", keychain.Service)); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range configFiles {
|
||||
src := filepath.Join(configDir, name)
|
||||
if err := addPortableFile(tw, src, path.Join("config", filepath.ToSlash(name))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImportPortableAuthBundle extracts a tar.gz auth bundle into the current
|
||||
// config and keychain locations.
|
||||
func ImportPortableAuthBundle(configDir string, r io.Reader) (PortableImportReport, error) {
|
||||
if r == nil {
|
||||
return PortableImportReport{}, fmt.Errorf("missing input reader")
|
||||
}
|
||||
gz, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return PortableImportReport{}, fmt.Errorf("open auth bundle: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
tr := tar.NewReader(gz)
|
||||
keychainDir := keychain.StorageDir(keychain.Service)
|
||||
var manifest portableAuthBundleManifest
|
||||
manifestRead := false
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return PortableImportReport{}, fmt.Errorf("read auth bundle: %w", err)
|
||||
}
|
||||
if hdr == nil {
|
||||
continue
|
||||
}
|
||||
cleanName, err := cleanPortableName(hdr.Name)
|
||||
if err != nil {
|
||||
return PortableImportReport{}, err
|
||||
}
|
||||
if cleanName == portableAuthManifest {
|
||||
if err := json.NewDecoder(tr).Decode(&manifest); err != nil {
|
||||
return PortableImportReport{}, fmt.Errorf("read auth bundle manifest: %w", err)
|
||||
}
|
||||
manifestRead = true
|
||||
continue
|
||||
}
|
||||
|
||||
var target string
|
||||
switch {
|
||||
case strings.HasPrefix(cleanName, "keychain/"+keychain.Service+"/"):
|
||||
rel := strings.TrimPrefix(cleanName, "keychain/"+keychain.Service+"/")
|
||||
target, err = safeJoin(keychainDir, rel)
|
||||
case strings.HasPrefix(cleanName, "config/"):
|
||||
rel := strings.TrimPrefix(cleanName, "config/")
|
||||
target, err = safeJoin(configDir, rel)
|
||||
default:
|
||||
return PortableImportReport{}, fmt.Errorf("unsupported auth bundle path %q", hdr.Name)
|
||||
}
|
||||
if err != nil {
|
||||
return PortableImportReport{}, err
|
||||
}
|
||||
if err := extractPortableEntry(target, hdr, tr); err != nil {
|
||||
return PortableImportReport{}, err
|
||||
}
|
||||
}
|
||||
report := PortableImportReport{}
|
||||
if manifestRead {
|
||||
report.BundleOS = manifest.OS
|
||||
report.OSMismatch = manifest.OS != "" && manifest.OS != runtime.GOOS
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func portableConfigFiles(configDir string) ([]string, error) {
|
||||
var files []string
|
||||
patterns := []string{"app*.json", "mcp_url", "terminal_url"}
|
||||
for _, pattern := range patterns {
|
||||
matches, err := filepath.Glob(filepath.Join(configDir, pattern))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan config files: %w", err)
|
||||
}
|
||||
for _, match := range matches {
|
||||
info, err := os.Stat(match)
|
||||
if err != nil || info.IsDir() {
|
||||
continue
|
||||
}
|
||||
rel, err := filepath.Rel(configDir, match)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve config file: %w", err)
|
||||
}
|
||||
files = append(files, rel)
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func writePortableManifest(tw *tar.Writer, manifest portableAuthBundleManifest) error {
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal auth bundle manifest: %w", err)
|
||||
}
|
||||
return writePortableBytes(tw, portableAuthManifest, append(data, '\n'), config.FilePerm)
|
||||
}
|
||||
|
||||
func addPortableDir(tw *tar.Writer, root, prefix string) error {
|
||||
return filepath.WalkDir(root, func(filePath string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
if entry.IsDir() {
|
||||
if filePath == root {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := path.Join(prefix, filepath.ToSlash(rel))
|
||||
return tw.WriteHeader(&tar.Header{Name: name, Typeflag: tar.TypeDir, Mode: int64(config.DirPerm)})
|
||||
}
|
||||
return addPortableFile(tw, filePath, path.Join(prefix, mustPortableRel(root, filePath)))
|
||||
})
|
||||
}
|
||||
|
||||
func mustPortableRel(root, filePath string) string {
|
||||
rel, err := filepath.Rel(root, filePath)
|
||||
if err != nil {
|
||||
return filepath.Base(filePath)
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
func addPortableFile(tw *tar.Writer, src, name string) error {
|
||||
info, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat %s: %w", src, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
file, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", src, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := tw.WriteHeader(&tar.Header{
|
||||
Name: path.Clean(name),
|
||||
Size: info.Size(),
|
||||
Mode: int64(config.FilePerm),
|
||||
ModTime: info.ModTime(),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write auth bundle header: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(tw, file); err != nil {
|
||||
return fmt.Errorf("write auth bundle file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writePortableBytes(tw *tar.Writer, name string, data []byte, mode os.FileMode) error {
|
||||
if err := tw.WriteHeader(&tar.Header{
|
||||
Name: path.Clean(name),
|
||||
Size: int64(len(data)),
|
||||
Mode: int64(mode),
|
||||
ModTime: time.Now(),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write auth bundle header: %w", err)
|
||||
}
|
||||
if _, err := tw.Write(data); err != nil {
|
||||
return fmt.Errorf("write auth bundle data: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanPortableName(name string) (string, error) {
|
||||
name = path.Clean(strings.TrimSpace(name))
|
||||
if name == "." || name == "/" || strings.HasPrefix(name, "../") || strings.HasPrefix(name, "/") {
|
||||
return "", fmt.Errorf("unsafe auth bundle path %q", name)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func safeJoin(root, rel string) (string, error) {
|
||||
if rel == "" {
|
||||
return "", fmt.Errorf("empty auth bundle path")
|
||||
}
|
||||
rel = filepath.FromSlash(path.Clean(rel))
|
||||
if filepath.IsAbs(rel) || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
|
||||
return "", fmt.Errorf("unsafe auth bundle path %q", rel)
|
||||
}
|
||||
target := filepath.Join(root, rel)
|
||||
cleanRoot := filepath.Clean(root) + string(filepath.Separator)
|
||||
if target != filepath.Clean(root) && !strings.HasPrefix(filepath.Clean(target)+string(filepath.Separator), cleanRoot) {
|
||||
return "", fmt.Errorf("unsafe auth bundle path %q", rel)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func extractPortableEntry(target string, hdr *tar.Header, r io.Reader) error {
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, config.DirPerm); err != nil {
|
||||
return fmt.Errorf("create auth bundle directory: %w", err)
|
||||
}
|
||||
return os.Chmod(target, config.DirPerm)
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(target), config.DirPerm); err != nil {
|
||||
return fmt.Errorf("create auth bundle directory: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create auth bundle temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
if err := tmp.Chmod(config.FilePerm); err != nil {
|
||||
return fmt.Errorf("set auth bundle file permissions: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(tmp, r); err != nil {
|
||||
return fmt.Errorf("write auth bundle file: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return fmt.Errorf("sync auth bundle file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close auth bundle file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, target); err != nil {
|
||||
return fmt.Errorf("install auth bundle file: %w", err)
|
||||
}
|
||||
success = true
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported auth bundle entry type %d for %q", hdr.Typeflag, hdr.Name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// 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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
func TestPortableExportSupported(t *testing.T) {
|
||||
if runtime.GOOS != "darwin" {
|
||||
if !PortableExportSupported() {
|
||||
t.Fatal("PortableExportSupported() should be true on non-darwin")
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Setenv(keychain.DisableKeychainEnv, "")
|
||||
if PortableExportSupported() {
|
||||
t.Fatal("PortableExportSupported() should be false on darwin without file DEK")
|
||||
}
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
if !PortableExportSupported() {
|
||||
t.Fatal("PortableExportSupported() should be true when file DEK is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPortableAuthBundleRequiresAuthToken(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
keychainRoot := filepath.Join(t.TempDir(), "empty-keychain")
|
||||
if err := os.MkdirAll(filepath.Join(keychainRoot, keychain.Service), 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
t.Setenv(keychain.StorageDirEnv, keychainRoot)
|
||||
configDir := filepath.Join(t.TempDir(), ".dws")
|
||||
|
||||
var bundle bytes.Buffer
|
||||
err := ExportPortableAuthBundle(configDir, &bundle)
|
||||
if err == nil {
|
||||
t.Fatal("ExportPortableAuthBundle() should fail without auth-token.enc")
|
||||
}
|
||||
if bundle.Len() != 0 {
|
||||
t.Fatalf("ExportPortableAuthBundle() wrote %d bytes, want 0", bundle.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortableAuthTargetPopulated(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
root := t.TempDir()
|
||||
configDir := filepath.Join(root, ".dws")
|
||||
t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain"))
|
||||
|
||||
if PortableAuthTargetPopulated(configDir) {
|
||||
t.Fatal("PortableAuthTargetPopulated() should be false before save")
|
||||
}
|
||||
if err := SaveTokenData(configDir, &TokenData{
|
||||
AccessToken: "token",
|
||||
RefreshToken: "refresh",
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if !PortableAuthTargetPopulated(configDir) {
|
||||
t.Fatal("PortableAuthTargetPopulated() should be true after save")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortableAuthBundleRoundTripPreservesRefreshToken(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
|
||||
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
|
||||
sourceConfig := filepath.Join(t.TempDir(), ".dws")
|
||||
|
||||
original := &TokenData{
|
||||
AccessToken: "access-source",
|
||||
RefreshToken: "refresh-source",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
|
||||
CorpID: "dingcorp",
|
||||
ClientID: "client-from-mcp",
|
||||
Source: "mcp",
|
||||
}
|
||||
if err := SaveTokenData(sourceConfig, original); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if err := SaveAppConfig(sourceConfig, &AppConfig{ClientID: "client-from-mcp"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
var bundle bytes.Buffer
|
||||
if err := ExportPortableAuthBundle(sourceConfig, &bundle); err != nil {
|
||||
t.Fatalf("ExportPortableAuthBundle() error = %v", err)
|
||||
}
|
||||
if bundle.Len() == 0 {
|
||||
t.Fatal("ExportPortableAuthBundle() wrote an empty bundle")
|
||||
}
|
||||
|
||||
targetKeychain := filepath.Join(t.TempDir(), "target-keychain")
|
||||
t.Setenv(keychain.StorageDirEnv, targetKeychain)
|
||||
targetConfig := filepath.Join(t.TempDir(), ".dws")
|
||||
|
||||
if _, err := ImportPortableAuthBundle(targetConfig, bytes.NewReader(bundle.Bytes())); err != nil {
|
||||
t.Fatalf("ImportPortableAuthBundle() error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadTokenData(targetConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() after import error = %v", err)
|
||||
}
|
||||
if loaded.AccessToken != original.AccessToken {
|
||||
t.Fatalf("access token = %q, want %q", loaded.AccessToken, original.AccessToken)
|
||||
}
|
||||
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 import")
|
||||
}
|
||||
if cfg, err := LoadAppConfig(targetConfig); err != nil {
|
||||
t.Fatalf("LoadAppConfig() after import error = %v", err)
|
||||
} else if cfg == nil || cfg.ClientID != "client-from-mcp" {
|
||||
t.Fatalf("imported app config = %#v, want client ID preserved", cfg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// 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"
|
||||
"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/auth test binary so that
|
||||
// SaveTokenData/DeleteTokenData calls in these tests can never write to
|
||||
// the developer's real keychain location, preventing cross-package leaks
|
||||
// when go test runs packages in parallel.
|
||||
func TestMain(m *testing.M) {
|
||||
tmpDir, err := os.MkdirTemp("", "dws-auth-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)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user