Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
d71031d097 | ||
|
|
46192d68ba | ||
|
|
eeb1782f7a | ||
|
|
4d0e194861 | ||
|
|
3046ff8e77 | ||
|
|
a2908d2cbf | ||
|
|
2f935586c8 | ||
|
|
89c6b737c0 | ||
|
|
bfe0e310fe | ||
|
|
fa1ab411e3 | ||
|
|
b247ef1266 | ||
|
|
c7ee0491b2 | ||
|
|
d06aa618e5 | ||
|
|
9bedec7ed2 | ||
|
|
0bc5345cf5 |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 49.8%"><title>coverage: 49.8%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">49.8%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">49.8%</text></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 54.2%"><title>coverage: 54.2%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">54.2%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">54.2%</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
+131
@@ -4,6 +4,133 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [1.0.19] - 2026-04-30
|
||||
|
||||
Discovery hardening for edition overlays: `edition.SupplementServers` / `FallbackServers` hooks now consistently surface through the **runtime catalog loader**, not just the static command tree, so overlay products that live outside the Portal envelope (e.g. Wukong gray-release `conference`) resolve an endpoint on both the cold-cache and tool-not-in-catalog paths. Ships with per-edition cache partitioning to stop cross-edition disk-cache leakage, plus a small todo fix.
|
||||
|
||||
### Added
|
||||
|
||||
- **`pkg/config.EditionPartition(name)`** (#197) — returns the cache partition key for a given edition. Open-source core (`""` / `"open"`) keeps using `DefaultPartition` (`default/default`); every other edition gets its own namespace (`<edition>/default`), preventing cross-edition data leakage in the shared `~/.dws` disk cache. Lives in `pkg/config` as a leaf helper so `internal/cli`, `internal/app`, and `internal/cache` can all call it without risking import cycles.
|
||||
- **`internal/editionmerge` shared package** (#197) — single source of truth for converting `edition.ServerInfo` into `market.ServerDescriptor` (`ToDescriptor`) and for merging `SupplementServers` / `FallbackServers` into a descriptor list. Both `internal/cli` (command tree) and `internal/app` (runtime catalog) now apply the edition hooks against the same discovery pipeline.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`EnvironmentLoader.loadFromCache` honors `SupplementServers` even on empty registry** (#197) — when the Portal registry cache is missing or empty, the catalog loader still materialises the edition's `SupplementServers` as endpoint-only `discovery.RuntimeServer` entries (source: `edition_supplement`), so hardcoded overlay commands for supplement-only products can still resolve an endpoint via the catalog path. Previously `loadFromCache` short-circuited to an empty catalog whenever the registry snapshot was empty, silently dropping gray-release products.
|
||||
- **Cache loader switches from `DefaultPartition` to `EditionPartition(edition.Get().Name)`** (#197) — the runtime catalog, registry snapshot, and tools snapshot are now partitioned per edition instead of all editions sharing `default/default`.
|
||||
- **`loadFromCache` appends supplement servers alongside fresh-cache servers** (#197) — supplement entries whose `CLI.ID` / `Key` are already present in the cached registry are skipped, so the hook never shadows Portal-published servers; only new products are added.
|
||||
- **`runtimeRunner.Run` falls through to `directRuntimeEndpoint` for supplement products** (#197) — when the catalog contains the product (e.g. supplied by `SupplementServers`) but the specific tool is not declared, the runner now trusts `directRuntimeEndpoint` to resolve a working endpoint for the tool before returning the explicit catalog-miss error. Supplement entries intentionally carry no tool list, so this is the path that makes overlay-only tools executable.
|
||||
- **Legacy `mergeSupplementServers` / `fallbackToDescriptors` moved out of `internal/app/legacy.go`** (#197) — relocated into `internal/editionmerge` and reused by the catalog loader, eliminating the duplicate `edition.ServerInfo → market.ServerDescriptor` logic that previously only ran on the static command-tree path.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws todo task get` returns empty** (#202) — the helper was calling `query_todo_detail`, which is not a valid MCP tool and returns empty. Switched to `get_todo_detail` as declared in `discovery.json`, restoring correct task-detail behaviour.
|
||||
- **Conference and other Wukong gray-release products miss endpoint on cold cache** (#197) — products registered only via `edition.SupplementServers` (not yet in the Portal envelope) now resolve an endpoint through the catalog path in both cold-start and tool-not-declared scenarios.
|
||||
|
||||
### Tests
|
||||
|
||||
- `internal/editionmerge/merge_test.go` — descriptor conversion + supplement/fallback merge semantics.
|
||||
- `internal/cli/loader_partition_test.go` + `loader_supplement_test.go` — edition-partitioned cache reads and supplement hook surfacing from `loadFromCache` (including empty-registry cold path and existing-ID deduplication).
|
||||
- `internal/app/legacy_wukong_partition_e2e_test.go` — end-to-end cache partition isolation for the Wukong edition.
|
||||
- `internal/app/runner_supplement_fallback_test.go` — runner falls through to `directRuntimeEndpoint` when the tool isn't declared by a supplement-sourced catalog entry.
|
||||
- `pkg/config/constants_test.go` — `EditionPartition` name handling (`""`, `"open"`, custom edition).
|
||||
|
||||
### Docs
|
||||
|
||||
- **CHANGELOG v1.0.18 rewrite** (#193) — previous release notes expanded to call out the PAT host-owned A-core flow, exit-code contract change (auth `4`, Discovery/cache/protocol `6`), `dws pat chmod` / `pat browser-policy` entry points, stderr-JSON classifier updates, and host-control metadata injection.
|
||||
|
||||
## [1.0.18] - 2026-04-28
|
||||
|
||||
Raw DingTalk OpenAPI access lands as a new `dws api` surface for both `api.dingtalk.com` and `oapi.dingtalk.com`, backed by app-level token caching and guarded host allowlists. PAT enters the host-owned **A-core** loop: agent hosts can own authorization UI through `DINGTALK_DWS_AGENTCODE`, parse single-line stderr JSON, call `dws pat chmod`, and replay the original command. Chat helper regressions are fixed, skill references are brought back in line with shipped commands, and the v1.0.17 Mail release notes are backfilled into README / CHANGELOG.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **PAT exit-code contract** (#142) — PAT authorization interceptions now use exit code `4`; Discovery, cache, and protocol negotiation failures now use exit code `6`. Downstream scripts that previously treated `4` as Discovery must update their handling.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws api` raw DingTalk OpenAPI command** (#184) — direct DingTalk OpenAPI calls without writing an MCP wrapper first. Supports `GET` / `POST` / `PUT` / `PATCH` / `DELETE`, JSON `--params` / `--data`, stdin input, dry-run previews, `--jq`, field selection, `--page-all`, `--page-limit`, `--page-delay`, and `--base-url`.
|
||||
- **Dual-form OpenAPI routing** (#184) — `api.dingtalk.com` requests use the `x-acs-dingtalk-access-token` header; `oapi.dingtalk.com` requests use the legacy `access_token` query parameter. The raw API client validates the target host before attaching credentials.
|
||||
- **App-level token cache for raw API** (#184) — custom-app credentials now fetch app access tokens from the unified OAuth endpoint, cache them while valid, and refresh them before expiry. The same token provider works for new-style and legacy OpenAPI calls.
|
||||
- **Host-owned PAT A-core flow** (#142) — when `DINGTALK_DWS_AGENTCODE` is set, PAT hits return `exit=4` plus single-line stderr JSON; the host renders authorization UI, calls `dws pat chmod <scope>...`, and replays the original command.
|
||||
- **`dws pat chmod` authorization entry point** (#142) — grants scopes with `--agentCode`, `--grant-type`, and session fallback support; `DINGTALK_DWS_AGENTCODE` can supply the agent code when the flag is omitted.
|
||||
- **PAT browser-open policy** (#142) — `dws pat browser-policy --enabled <true|false> [--agentCode <id>]` controls whether the CLI may open a browser, independently from `--format` output mode.
|
||||
|
||||
### Changed
|
||||
|
||||
- **README raw API guide** (#184) — English and Chinese READMEs now document custom-app prerequisites, api/oapi examples, auto-pagination, dry-run, jq filtering, security properties, and the new Raw API service-table row.
|
||||
- **Raw API token retrieval path** (#184) — token lookup now goes through a single app-token interface; stale auth-refresh retry helpers were removed from the raw API path.
|
||||
- **PAT stderr JSON classifier** (#142) — recognizes `code`, `errorCode`, and `error_code`, including `PAT_NO_PERMISSION`, risk-tier PAT errors, `PAT_SCOPE_AUTH_REQUIRED`, and `AGENT_CODE_NOT_EXISTS`.
|
||||
- **Host-control metadata injection** (#142) — classifier and active-retry paths now share one mutation point for `data.hostControl` and `data.openBrowser`, keeping host-facing JSON shapes aligned.
|
||||
- **Open-edition routing signals** (#142) — open edition pins `claw-type: openClaw`; `DINGTALK_AGENT`, `DWS_CHANNEL`, and host-owned PAT detection are kept as independent signals.
|
||||
- **Behavior authorization endpoint fallback** (#142) — the PAT runtime can resolve the built-in behavior-authorization MCP endpoint before discovery data is available.
|
||||
- **v1.0.17 documentation backfill** (#181) — the previous release notes and README service table now explicitly include the shipped Mail product, update the total to **163 commands across 14 products**, and remove Mail from "Coming soon".
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CLI auth-denial attribution** — local CLI authorization denials are attributed to the channel before falling back to user-scope classification, avoiding user-scope misclassification for channel-level auth failures.
|
||||
- **Opaque authorization URLs** (#182, #142) — PAT authorization links are preserved verbatim, including query/hash/fragment content required by the server.
|
||||
- **Polling compatibility** (#182, #142) — device-flow result envelopes and no-`flowId` device-code fallback remain supported, with guarded debug output and envelope priority.
|
||||
- **Group chat @-mentions restored** (#180) — `dws chat message send --group ...` again accepts and forwards `--at-users`, `--at-all`, and `--at-mobiles`; those flags are rejected outside group-chat mode so single-chat sends cannot silently drop @-mention intent.
|
||||
- **Explicit members-list command restored** (#180) — `dws chat group members list --id <openConversationId>` is reachable after the helper/dynamic merge path changed. `cmdutil.MergeHardcodedLeaves` now honors higher-priority helper groups when a dynamic envelope contributes a leaf at the same path.
|
||||
- **Skill reference command names** (#186) — `simple.md` now uses shipped OA command names (`list-pending`, `list-initiated`), removes a non-existent devdoc `search-error` command, and marks `workbench.md` as Draft because workbench commands are not available in the runtime.
|
||||
- **Empty grant result handling** (#142) — `dws pat chmod` now returns an explicit error instead of treating `{"Content": null}` as success.
|
||||
- **Session-id log safety** (#142) — raw `DWS_SESSION_ID` / `REWIND_SESSION_ID` values are no longer logged when the two env vars disagree.
|
||||
|
||||
### Tests
|
||||
|
||||
- Added raw API coverage for request validation, api/oapi routing, token management, pagination, response handling, dry-run output, JSON parsing, stdin handling, and command wiring. (#184)
|
||||
- Added chat/cmdutil regression tests for group @-mention forwarding, single-chat rejection, `members list`, helper-vs-envelope shape mismatch, and merge-priority behavior. (#180)
|
||||
- Added PAT contract coverage for host-owned signal selection, single-line stderr JSON, chmod env fallback and legacy alias fallback, browser policy, direct-runtime PAT endpoint fallback, and retry/poll behavior. (#142)
|
||||
- Coverage badge refreshed after the post-v1.0.17 CI runs.
|
||||
|
||||
## [1.0.17] - 2026-04-27
|
||||
|
||||
New **Mail** product surface (mailbox list, KQL message search, message get, send) brings runtime command count to **163 across 14 products**. Plugin command-tree visibility hardening: stdio plugins shipping CLI overlays no longer wait on subprocess discovery to surface their commands, and overlay-registered plugin products are no longer hidden by edition `VisibleProducts` whitelists. Chat docs clarify that `--title` is required on `dws chat message send`.
|
||||
|
||||
### Added
|
||||
|
||||
- **`mail` product** (#167) — new top-level service for DingTalk Mail. Four leaf commands across two subgroups:
|
||||
- `dws mail mailbox list` — list mailbox addresses available to the current user (`list_user_mailboxes`)
|
||||
- `dws mail message search` — KQL search across folders / sender / date / attachments / read-state (`search_emails`); supports `--cursor` pagination
|
||||
- `dws mail message get` — fetch full message body + headers + attachments by message ID (`get_email_by_message_id`)
|
||||
- `dws mail message send` — send email to one or more recipients (`send_email`)
|
||||
- Skill reference at `skills/references/products/mail.md` registered in `skills/SKILL.md` master index and intent decision tree
|
||||
- **Stdio plugin overlay-first command registration** (#179) — when a stdio plugin's `overlay.json` declares `toolOverrides`, command trees are built from manifest metadata synchronously at startup, no subprocess `Initialize` / `tools/list` handshake required. Previously, slow or failing subprocesses left plugin commands invisible in `dws --help`. Background discovery still runs to refresh the warm cache for richer flag types on subsequent startups.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`hideNonDirectRuntimeCommands` / `visibleMCPRootCommands` / `visibleUtilityRootCommands`** (#179) — refactored to share a single `resolveVisibleProducts()` helper that **unions** the edition's `VisibleProducts` hook with `DirectRuntimeProductIDs()`, so plugins registered via `AppendDynamicServer` stay visible in `dws --help` even when an edition installs a static product whitelist. Previously the hook fully replaced the dynamic registry, silently hiding plugin commands.
|
||||
- **`dws chat message send` documentation clarifies `--title` is required** (#174) — the helper command short text and the chat skill reference now state explicitly that `--title` is mandatory for both group and single-chat sends, matching the runtime validation.
|
||||
- **`buildStdioCommands` refactored to share helpers with the overlay-first path** (#179) — overlay parsing (`resolveStdioOverlay`) and tools→DetailTool conversion (`toolsToDetails`) extracted as package-level helpers; the legacy discovery-first stdio path now delegates to them, eliminating duplicated overlay JSON / cache-snapshot logic.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Negative-cache poisoning guard for stdio plugin discovery** (#179) — `refreshStdioToolsCache` now skips `SaveTools` entirely when discovery returns an empty tool list (transient failure, subprocess not ready, RPC timeout), so a single bad refresh cannot overwrite a previously-good cache and degrade flag enrichment on the next startup.
|
||||
|
||||
### Tests
|
||||
|
||||
- 6 new test cases in `internal/app/plugin_stdio_overlay_test.go` and `internal/app/visibility_test.go` cover overlay-first registration without discovery, warm-cache flag enrichment from `InputSchema`, fallback when overlays lack `toolOverrides`, the cache-poisoning guard, and integration cases for plugin visibility under restrictive `VisibleProducts` whitelists.
|
||||
- Coverage 49.8% → 52.8%.
|
||||
|
||||
## [1.0.16] - 2026-04-24
|
||||
|
||||
Discovery service abstraction with schema v3 extensions, open-edition helper-subtree restoration, and a defensive device-flow login reset.
|
||||
|
||||
### Added
|
||||
|
||||
- **`internal/discovery` service abstraction** (#156) — encapsulates market registry fetch, MCP runtime negotiation (`initialize → tools/list → detail` merge), and multi-level cache fallback. `EnvironmentLoader` now does cache-first startup, with degraded-mode reasons (`unauthenticated` / `market_unreachable` / `runtime_all_failed`) and `UpdatedAt`-based selective re-discovery.
|
||||
- **Schema v3 extensions** (#156) — positional parameters with typed coercion, `Example` on `--help`, flag `Default` / `RuntimeDefault` (with `$currentUserId` / `$now` etc.), `BodyWrapper`, `MutuallyExclusive` / `RequireOneOf` flag groups, `OmitWhen`, explicit `Type` override, and detail-schema `default` propagation.
|
||||
- **`dws chat message send` destination-flag routing** (#170) — open edition gains a hardcoded helper that dispatches by `--group` (→ `send_message_as_user`) vs `--user` / `--open-dingtalk-id` (→ `send_direct_message_as_user`), mirroring the closed-source overlay so single-chat sends finally work end-to-end.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`pickCommands` → `cmdutil.MergeHardcodedLeaves`** (#169) — when a top-level product name collides between the dynamic overlay and a helper subtree, helper-only siblings are grafted into the dynamic tree instead of dropped. Restores `dws chat message send-by-bot` / `recall-by-bot` / `send-by-webhook` and `dws chat group members add-bot`, which had silently vanished from the open edition.
|
||||
- **`OverridePriority` / `MergeHardcodedLeaves` promoted into `pkg/cmdutil`** (#170) — single source of truth for the merge layer; hardcoded leaves can opt into overriding the dynamic envelope via a strictly higher priority.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Device flow defensively resets credentials before login** (#157) — `--device` login now clears stale credential state and re-fetches `clientID` from the MCP server, regardless of what previous login methods (OAuth scan, PAT) left in `app.json`. Fixes the case where a prior OAuth login made `--device` fall back to direct mode and demand `clientSecret`.
|
||||
|
||||
## [1.0.15] - 2026-04-23
|
||||
|
||||
Compat layer gains **subcommand merging** under shared parents so multiple server entries can contribute into the same `dws <parent> <branch>` subtree without producing duplicate `--help` rows. Ships with a fresh auto-generated command index doc, a README sync to **159 commands across 13 products**, and a wide-ranging flag-naming cleanup that standardises CLI flags across chat, calendar, drive, minutes, contact, and devdoc commands.
|
||||
@@ -46,6 +173,10 @@ Compat layer gains **subcommand merging** under shared parents so multiple serve
|
||||
- `TestBuildDynamicCommands_ParentMergeRecursive` — recursive merge through nested groups (e.g. `chat.group.members`)
|
||||
- `TestBuildDynamicCommands_ParentMergeLeafCollision` — identical leaf paths resolve first-wins without producing duplicates
|
||||
|
||||
## [1.0.14] - 2026-04-22
|
||||
|
||||
Docs-only re-tag of v1.0.13. The single commit (#153) backfills the v1.0.13 release notes after the binary was already published; no functional or CLI surface change.
|
||||
|
||||
## [1.0.13] - 2026-04-22
|
||||
|
||||
IM / Messaging capability expansion: the `chat` (aka `im`) product surface grows from "group + bot messaging" into a full conversational layer — user-identity messaging, message reading & search, personal messages, topic replies, mentions, focused contacts, unread/top/common conversations, org-wide group creation, and first-class bot lifecycle.
|
||||
|
||||
@@ -194,7 +194,7 @@ dws todo task create --title "Quarterly report" --executors "<your-userId>" #
|
||||
dws todo task list --dry-run # preview without executing
|
||||
```
|
||||
|
||||
> **Full command list**: [`docs/command-index.md`](./docs/command-index.md) — all 159 commands with descriptions and when-to-use guidance.
|
||||
> **Full command list**: [`docs/command-index.md`](./docs/command-index.md) — all commands with descriptions and when-to-use guidance.
|
||||
|
||||
## Using with Agents
|
||||
|
||||
@@ -207,7 +207,7 @@ dws is designed as an AI-native CLI. Complete [Installation](#installation) and
|
||||
dws todo task create --title "Review PR" --executors "<your-userId>" --yes
|
||||
|
||||
# Use --dry-run to preview operations (safe execution)
|
||||
dws contact user search --keyword "engineering" --dry-run
|
||||
dws contact user search --query "engineering" --dry-run
|
||||
|
||||
# Use --jq to extract precisely (save tokens)
|
||||
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
|
||||
@@ -276,6 +276,52 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
|
||||
## Features
|
||||
|
||||
<details>
|
||||
<summary><strong>Raw API Access</strong> — call any DingTalk OpenAPI directly</summary>
|
||||
|
||||
`dws api` lets you call any DingTalk OpenAPI without an SDK. Tokens are automatically acquired and refreshed.
|
||||
|
||||
> **Prerequisite**: Must login with your own app credentials (see [Custom App mode](#getting-started)). Encrypted tokens from MCP default-credential login are not supported for raw API calls.
|
||||
|
||||
```bash
|
||||
# Login (first time only)
|
||||
dws auth login --client-id <APP_KEY> --client-secret <APP_SECRET>
|
||||
|
||||
# === api.dingtalk.com ===
|
||||
|
||||
# List all enterprise apps
|
||||
dws api GET /v1.0/microApp/allApps
|
||||
|
||||
# Search users (POST + JSON body)
|
||||
dws api POST /v1.0/contact/users/search \
|
||||
--data '{"queryWord":"engineering","offset":0,"size":10}'
|
||||
|
||||
# === oapi.dingtalk.com ===
|
||||
|
||||
# Get user details (use --base-url to specify domain)
|
||||
dws api POST /topapi/v2/user/get \
|
||||
--base-url https://oapi.dingtalk.com \
|
||||
--data '{"userid":"<USER_ID>"}'
|
||||
|
||||
# Or use the full URL directly
|
||||
dws api POST https://oapi.dingtalk.com/topapi/v2/user/get \
|
||||
--data '{"userid":"<USER_ID>"}'
|
||||
|
||||
# === General ===
|
||||
dws api GET /v1.0/microApp/allApps --page-all # auto-paginate
|
||||
dws api GET /v1.0/microApp/allApps --dry-run # preview request
|
||||
dws api GET /v1.0/microApp/allApps --jq '.agentId' # jq filtering
|
||||
```
|
||||
|
||||
| Feature | Details |
|
||||
|---------|----------|
|
||||
| Dual-form auto-detection | Automatically selects api.dingtalk.com (header auth) or oapi.dingtalk.com (query-param auth) based on URL |
|
||||
| Automatic token management | App-level accessToken is fetched on first call, cached while valid, auto-refreshed on expiry |
|
||||
| Domain allowlist | Only `api.dingtalk.com` and `oapi.dingtalk.com` permitted — prevents token leakage |
|
||||
| Auto-pagination | `--page-all` iterates all pages. `--page-limit` caps the maximum (default 10, set to 0 for unlimited, hard cap at 500 to prevent infinite loops) |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Smart Input Correction</strong> — auto-corrects common AI model parameter mistakes</summary>
|
||||
|
||||
@@ -286,7 +332,7 @@ Built-in pipeline engine that normalizes flag names, splits sticky arguments, an
|
||||
dws aitable record query --baseId BASE_ID --tableId TABLE_ID # auto-corrected to --base-id --table-id
|
||||
|
||||
# Sticky argument splitting
|
||||
dws contact user search --keyword "engineering" --timeout30 # auto-split to --timeout 30
|
||||
dws contact user search --query "engineering" --timeout30 # auto-split to --timeout 30
|
||||
|
||||
# Fuzzy flag name matching
|
||||
dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-id -> --table-id
|
||||
@@ -366,16 +412,18 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| Doc | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), upload / download |
|
||||
| Drive | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | DingTalk drive file ops: list, info, download, create folders, two-phase upload |
|
||||
| Minutes | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | List AI meeting notes (mine / shared), details (info / summary / keywords / transcription / todos / batch), title/summary updates, mind map, speaker replace, hot-word, upload session |
|
||||
| Mail | `mail` | 4 | `mailbox` `message` | List mailbox addresses, KQL message search, get full message content, send email |
|
||||
| DevDoc | `devdoc` | 1 | `article` | Search the DingTalk Open Platform documentation |
|
||||
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
|
||||
|
||||
> **159 commands across 13 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
|
||||
> **163 commands across 14 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
|
||||
|
||||
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
|
||||
|
||||
<details>
|
||||
<summary>Coming soon</summary>
|
||||
|
||||
`mail` (email) · `conference` (video) · `aiapp` (AI apps) · `live` (streaming) · `wiki` (knowledge base)
|
||||
`conference` (video) · `aiapp` (AI apps) · `live` (streaming) · `wiki` (knowledge base)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -424,7 +472,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
|
||||
## Reference & Docs
|
||||
|
||||
- [Command Index](./docs/command-index.md) — every runtime command (159 total) with description and when-to-use guidance
|
||||
- [Command Index](./docs/command-index.md) — every runtime command with description and when-to-use guidance
|
||||
- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
|
||||
- [Architecture](./docs/architecture.md) — discovery-driven pipeline, IR, transport layer
|
||||
- [Changelog](./CHANGELOG.md) — release history and migration notes
|
||||
|
||||
+54
-6
@@ -194,7 +194,7 @@ dws todo task create --title "季度汇报" --executors "<your-userId>" # 创
|
||||
dws todo task list --dry-run # 预览操作但不执行
|
||||
```
|
||||
|
||||
> **完整命令列表**:[`docs/command-index.md`](./docs/command-index.md) — 全部 159 条命令,带描述和使用场景。
|
||||
> **完整命令列表**:[`docs/command-index.md`](./docs/command-index.md) — 全部命令,带描述和使用场景。
|
||||
|
||||
## 在 Agent 中使用
|
||||
|
||||
@@ -207,7 +207,7 @@ dws 是为 AI Agent 设计的 CLI 工具。请先完成[安装](#安装)和[开
|
||||
dws todo task create --title "Review PR" --executors "<your-userId>" --yes
|
||||
|
||||
# 使用 --dry-run 预览操作(安全执行)
|
||||
dws contact user search --keyword "张三" --dry-run
|
||||
dws contact user search --query "张三" --dry-run
|
||||
|
||||
# 使用 --jq 精确提取(节省 token)
|
||||
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
|
||||
@@ -276,6 +276,52 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
|
||||
## 功能特性
|
||||
|
||||
<details>
|
||||
<summary><strong>Raw API 调用</strong> — 直接调用钉钉 OpenAPI</summary>
|
||||
|
||||
`dws api` 让你直接调用任意钉钉 OpenAPI,无需 SDK,Token 自动获取和刷新。
|
||||
|
||||
> **前置条件**:必须使用自有应用凭证登录(见[自建应用模式](#开始使用))。通过 MCP 默认凭证登录 不支持 raw API 调用。
|
||||
|
||||
```bash
|
||||
# 登录(仅首次)
|
||||
dws auth login --client-id <APP_KEY> --client-secret <APP_SECRET>
|
||||
|
||||
# === api.dingtalk.com ===
|
||||
|
||||
# 获取企业所有应用列表
|
||||
dws api GET /v1.0/microApp/allApps
|
||||
|
||||
# 搜索用户 (POST + JSON body)
|
||||
dws api POST /v1.0/contact/users/search \
|
||||
--data '{"queryWord":"张三","offset":0,"size":10}'
|
||||
|
||||
# === oapi.dingtalk.com ===
|
||||
|
||||
# 获取用户详情(使用 --base-url 指定域名)
|
||||
dws api POST /topapi/v2/user/get \
|
||||
--base-url https://oapi.dingtalk.com \
|
||||
--data '{"userid":"<USER_ID>"}'
|
||||
|
||||
# 也可以直接使用完整 URL
|
||||
dws api POST https://oapi.dingtalk.com/topapi/v2/user/get \
|
||||
--data '{"userid":"<USER_ID>"}'
|
||||
|
||||
# === 通用功能 ===
|
||||
dws api GET /v1.0/microApp/allApps --page-all # 自动翻页
|
||||
dws api GET /v1.0/microApp/allApps --dry-run # 预览请求
|
||||
dws api GET /v1.0/microApp/allApps --jq '.agentId' # jq 过滤
|
||||
```
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 双形态自动识别 | 根据 URL 自动选择 api.dingtalk.com(Header 认证)或 oapi.dingtalk.com(Query 参数认证) |
|
||||
| Token 自动管理 | 首次调用自动获取应用级 accessToken,有效期内缓存,过期自动刷新 |
|
||||
| 域名白名单 | 仅允许 `api.dingtalk.com` 和 `oapi.dingtalk.com`,防止 Token 泄露 |
|
||||
| 自动分页 | `--page-all` 自动遍历所有分页。`--page-limit` 控制翻页上限(默认 10,设为 0 不限制,硬上限 500 防止死循环) |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>智能输入纠错</strong> — 自动修正 AI 模型常见的参数错误</summary>
|
||||
|
||||
@@ -286,7 +332,7 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
dws aitable record query --baseId BASE_ID --tableId TABLE_ID # 自动纠正为 --base-id --table-id
|
||||
|
||||
# 粘连参数自动拆分
|
||||
dws contact user search --keyword "张三" --timeout30 # 自动拆分为 --timeout 30
|
||||
dws contact user search --query "张三" --timeout30 # 自动拆分为 --timeout 30
|
||||
|
||||
# 拼写错误模糊匹配
|
||||
dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-id → --table-id
|
||||
@@ -366,16 +412,18 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| 文档 | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | 搜索 / 读写文档、文件与文件夹创建、块级编辑、评论(list / create / reply / create-inline)、上传 / 下载 |
|
||||
| 钉盘 | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | 钉盘文件操作:列表、详情、下载、创建文件夹、两阶段上传 |
|
||||
| AI 听记 | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | 听记列表(我创建 / 共享给我)、详情(info / summary / keywords / transcription / todos / batch)、标题/摘要更新、思维导图、发言人替换、热词、上传会话 |
|
||||
| 邮箱 | `mail` | 4 | `mailbox` `message` | 邮箱地址列表、KQL 邮件搜索、邮件详情、发送邮件 |
|
||||
| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 |
|
||||
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
|
||||
|
||||
> **13 个产品,159 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
> **14 个产品,163 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
|
||||
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
|
||||
|
||||
<details>
|
||||
<summary>即将推出</summary>
|
||||
|
||||
`mail`(邮箱)· `conference`(视频会议)· `aiapp`(AI 应用)· `live`(直播)· `wiki`(知识库)
|
||||
`conference`(视频会议)· `aiapp`(AI 应用)· `live`(直播)· `wiki`(知识库)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -426,7 +474,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
|
||||
## 参考与文档
|
||||
|
||||
- [命令索引](./docs/command-index.md) — 159 条运行时命令,带描述与使用场景
|
||||
- [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景
|
||||
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
|
||||
- [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层
|
||||
- [更新日志](./CHANGELOG.md) — 版本历史与迁移说明
|
||||
|
||||
@@ -53,6 +53,7 @@ __KEG_ONLY_LINE__
|
||||
Pathname.new(File.join(Dir.home, ".kiro/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".trae/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".openclaw/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".hermes/skills/dws")),
|
||||
]
|
||||
|
||||
targets.each_with_index do |dest, index|
|
||||
|
||||
@@ -22,6 +22,7 @@ const AGENT_DIRS = [
|
||||
".kiro/skills",
|
||||
".trae/skills",
|
||||
".openclaw/skills",
|
||||
".hermes/skills",
|
||||
];
|
||||
|
||||
const PLATFORM_MAP = {
|
||||
|
||||
+6
-5
@@ -19,8 +19,9 @@
|
||||
| 1 | API | MCP tool call or upstream API failure / MCP 工具调用或上游 API 失败 |
|
||||
| 2 | Auth | Authentication or authorization failure / 身份认证或授权失败 |
|
||||
| 3 | Validation | Invalid input, flags, or parameter schema mismatch / 输入参数校验失败 |
|
||||
| 4 | Discovery | Server discovery, cache, or protocol negotiation failure / 服务发现失败 |
|
||||
| 4 | PAT | PAT authorization interception; stderr carries raw machine-readable PAT JSON / PAT 授权拦截;stderr 返回原始机器可解析 JSON |
|
||||
| 5 | Internal | Unexpected internal error / 未预期的内部错误 |
|
||||
| 6 | Discovery | Server discovery, cache, or protocol negotiation failure / 服务发现、缓存或协议协商失败 |
|
||||
|
||||
With `-f json`, error responses include structured payloads: `category`, `reason`, `hint`, `actions`.
|
||||
|
||||
@@ -29,9 +30,9 @@ With `-f json`, error responses include structured payloads: `category`, `reason
|
||||
## Output Formats / 输出格式
|
||||
|
||||
```bash
|
||||
dws contact user search --keyword "Alice" -f table # Table (default, human-friendly / 表格,默认)
|
||||
dws contact user search --keyword "Alice" -f json # JSON (for agents and piping / 适合 agent)
|
||||
dws contact user search --keyword "Alice" -f raw # Raw API response / 原始响应
|
||||
dws contact user search --query "Alice" -f table # Table (default, human-friendly / 表格,默认)
|
||||
dws contact user search --query "Alice" -f json # JSON (for agents and piping / 适合 agent)
|
||||
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
|
||||
dws schema -f pretty ding.send_ding_message # Pretty (ANSI-colored, schema-aware / 彩色分区,专为 schema 设计)
|
||||
```
|
||||
|
||||
@@ -44,7 +45,7 @@ dws todo task list --dry-run # Preview MCP call without executing / 预览但
|
||||
## Output to File / 输出到文件
|
||||
|
||||
```bash
|
||||
dws contact user search --keyword "Alice" -o result.json
|
||||
dws contact user search --query "Alice" -o result.json
|
||||
```
|
||||
|
||||
## Schema Introspection / Schema 查询
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package apiclient provides a lightweight HTTP client for calling DingTalk
|
||||
// OpenAPI (https://api.dingtalk.com) directly, bypassing the MCP JSON-RPC
|
||||
// transport. It is used exclusively by the `dws api` command.
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultBaseURL is the DingTalk new-style OpenAPI base URL.
|
||||
DefaultBaseURL = "https://api.dingtalk.com"
|
||||
|
||||
// LegacyBaseURL is the DingTalk legacy (oapi) API base URL.
|
||||
LegacyBaseURL = "https://oapi.dingtalk.com"
|
||||
|
||||
// AuthHeader is the new-style OpenAPI authentication header.
|
||||
AuthHeader = "x-acs-dingtalk-access-token"
|
||||
|
||||
// LegacyAuthParam is the query parameter used for legacy API authentication.
|
||||
LegacyAuthParam = "access_token"
|
||||
)
|
||||
|
||||
// AllowedMethods is the set of HTTP methods permitted for raw API calls.
|
||||
var AllowedMethods = map[string]bool{
|
||||
"GET": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true,
|
||||
}
|
||||
|
||||
// RawAPIRequest describes a raw API request to DingTalk OpenAPI.
|
||||
type RawAPIRequest struct {
|
||||
Method string // GET, POST, PUT, PATCH, DELETE
|
||||
Path string // /v1.0/calendar/events or full URL
|
||||
Params map[string]any // query parameters
|
||||
Data any // request body (JSON), nil for GET
|
||||
}
|
||||
|
||||
// RawAPIResponse encapsulates the raw HTTP response.
|
||||
type RawAPIResponse struct {
|
||||
StatusCode int
|
||||
Header http.Header
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// APIClient wraps an HTTP client for DingTalk OpenAPI calls.
|
||||
type APIClient struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
Token string
|
||||
}
|
||||
|
||||
// NewClient creates an APIClient with sensible defaults.
|
||||
func NewClient(token, baseURL string) *APIClient {
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
baseURL = DefaultBaseURL
|
||||
}
|
||||
return &APIClient{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
Token: token,
|
||||
HTTPClient: &http.Client{
|
||||
Transport: defaultTransport(),
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Do sends a raw API request and returns the response.
|
||||
func (c *APIClient) Do(ctx context.Context, req RawAPIRequest) (*RawAPIResponse, error) {
|
||||
method := strings.ToUpper(strings.TrimSpace(req.Method))
|
||||
if !AllowedMethods[method] {
|
||||
return nil, fmt.Errorf("unsupported HTTP method: %s (allowed: GET, POST, PUT, PATCH, DELETE)", req.Method)
|
||||
}
|
||||
|
||||
fullURL, err := c.buildURL(req.Path, req.Params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building request URL: %w", err)
|
||||
}
|
||||
|
||||
// Security: verify target host before sending token.
|
||||
if err := ValidateTargetHost(fullURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if req.Data != nil && method != "GET" {
|
||||
data, marshalErr := json.Marshal(req.Data)
|
||||
if marshalErr != nil {
|
||||
return nil, fmt.Errorf("marshaling request body: %w", marshalErr)
|
||||
}
|
||||
bodyReader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating HTTP request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers and auth based on API style.
|
||||
if IsLegacyAPI(fullURL) {
|
||||
// Legacy API: token goes in query parameter.
|
||||
parsed, _ := url.Parse(fullURL)
|
||||
q := parsed.Query()
|
||||
q.Set(LegacyAuthParam, c.Token)
|
||||
parsed.RawQuery = q.Encode()
|
||||
httpReq.URL = parsed
|
||||
} else {
|
||||
// New API: token goes in header.
|
||||
httpReq.Header.Set(AuthHeader, c.Token)
|
||||
}
|
||||
if bodyReader != nil {
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
httpReq.Header.Set("User-Agent", "dws-cli/raw-api")
|
||||
|
||||
resp, err := c.HTTPClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("executing HTTP request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response body: %w", err)
|
||||
}
|
||||
|
||||
return &RawAPIResponse{
|
||||
StatusCode: resp.StatusCode,
|
||||
Header: resp.Header,
|
||||
Body: body,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildURL constructs the full request URL from path and query params.
|
||||
func (c *APIClient) buildURL(path string, params map[string]any) (string, error) {
|
||||
normalised := NormalisePath(path, c.BaseURL)
|
||||
parsed, err := url.Parse(normalised)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parsing URL %q: %w", normalised, err)
|
||||
}
|
||||
|
||||
if len(params) > 0 {
|
||||
q := parsed.Query()
|
||||
for k, v := range params {
|
||||
q.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
parsed.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
// IsLegacyAPI returns true if the URL targets the legacy oapi.dingtalk.com endpoint.
|
||||
// Legacy APIs use query-parameter authentication instead of header-based auth.
|
||||
func IsLegacyAPI(urlStr string) bool {
|
||||
lower := strings.ToLower(urlStr)
|
||||
return strings.Contains(lower, "oapi.dingtalk.com") ||
|
||||
strings.HasPrefix(lower, LegacyBaseURL)
|
||||
}
|
||||
|
||||
// NormalisePath normalises an API path:
|
||||
// - Full URLs are accepted as-is (after stripping query/fragment)
|
||||
// - Relative paths are prefixed with the base URL
|
||||
// - Query strings and fragments are stripped (must use --params)
|
||||
func NormalisePath(path, baseURL string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
|
||||
// Strip query and fragment to force --params usage.
|
||||
if idx := strings.IndexAny(path, "?#"); idx >= 0 {
|
||||
path = path[:idx]
|
||||
}
|
||||
|
||||
// Full URL: extract the path portion relative to the base.
|
||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
return path
|
||||
}
|
||||
|
||||
// Ensure leading slash.
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
baseURL = DefaultBaseURL
|
||||
}
|
||||
return strings.TrimRight(baseURL, "/") + path
|
||||
}
|
||||
|
||||
// defaultTransport returns a tuned http.Transport matching the project conventions.
|
||||
func defaultTransport() *http.Transport {
|
||||
return &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 3 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: 20 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
}
|
||||
}
|
||||
@@ -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,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,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
|
||||
}
|
||||
@@ -198,9 +198,14 @@ func newAuthLogoutCommand() *cobra.Command {
|
||||
if err := authpkg.DeleteTokenData(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
||||
}
|
||||
// Clean up associated client secret from keychain
|
||||
// Clean up associated client secret and app token from keychain
|
||||
if storedClientID != "" {
|
||||
_ = authpkg.DeleteClientSecret(storedClientID)
|
||||
_ = authpkg.DeleteAppTokenData(storedClientID)
|
||||
}
|
||||
// Also try cleaning app token using appKey from app config
|
||||
if appKey, _ := authpkg.ResolveAppCredentials(configDir); appKey != "" && appKey != storedClientID {
|
||||
_ = authpkg.DeleteAppTokenData(appKey)
|
||||
}
|
||||
// Clean up app credentials (app.json + keychain secret)
|
||||
_ = authpkg.DeleteAppConfig(configDir)
|
||||
|
||||
@@ -15,9 +15,6 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/authretry"
|
||||
)
|
||||
|
||||
// authRetryingKey marks a context that has already attempted one
|
||||
@@ -37,47 +34,3 @@ func IsAuthRetrying(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(authRetryingKey).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// withAuthRetrying returns a child context flagged as "already retried once"
|
||||
// so the runner does not enter an infinite refresh loop if the second attempt
|
||||
// also returns AuthRefreshRequired.
|
||||
func withAuthRetrying(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithValue(ctx, authRetryingKey, true)
|
||||
}
|
||||
|
||||
// handleAuthRefreshRequired performs a one-shot ForceRefresh using the active
|
||||
// configDir and re-runs the invocation through the supplied runner. It must
|
||||
// only be called when the runner has observed an *authretry.AuthRefreshRequired
|
||||
// from an edition hook (ClassifyToolResult / OnAuthError).
|
||||
//
|
||||
// Behaviour rules — all three matter for safety:
|
||||
// 1. If the context is already flagged via IsAuthRetrying, this returns
|
||||
// refresh.Cause unchanged. No further refresh attempts, no recursion.
|
||||
// 2. If ForceRefresh fails (e.g. refresh_token also expired), this returns
|
||||
// refresh.Cause so the user sees the original auth diagnostic, not an
|
||||
// internal "force refresh failed" message.
|
||||
// 3. On successful refresh, this resets the per-process token cache and
|
||||
// re-runs the invocation with withAuthRetrying applied so a second
|
||||
// refresh request from the overlay degrades gracefully to "show the
|
||||
// original error".
|
||||
func handleAuthRefreshRequired(
|
||||
ctx context.Context,
|
||||
r executor.Runner,
|
||||
invocation executor.Invocation,
|
||||
refresh *authretry.AuthRefreshRequired,
|
||||
) (executor.Result, error) {
|
||||
if refresh == nil {
|
||||
return executor.Result{}, nil
|
||||
}
|
||||
if IsAuthRetrying(ctx) {
|
||||
return executor.Result{}, refresh.Cause
|
||||
}
|
||||
if _, err := ForceRefreshAccessToken(ctx, defaultConfigDir()); err != nil {
|
||||
return executor.Result{}, refresh.Cause
|
||||
}
|
||||
ResetRuntimeTokenCache()
|
||||
return r.Run(withAuthRetrying(ctx), invocation)
|
||||
}
|
||||
|
||||
@@ -14,10 +14,13 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
@@ -38,6 +41,57 @@ var legacyDirectRuntimeAliases = map[string]string{
|
||||
"dingtalk-ai-sincere-hire": "ai-sincere-hire",
|
||||
}
|
||||
|
||||
const (
|
||||
defaultPATProductID = "pat"
|
||||
defaultPATDisplayName = "行为授权"
|
||||
defaultPATServerID = "abc3c880fb90f04b52d1426aaf093766e5fc9ec38411688cbb74df42a584d374"
|
||||
)
|
||||
|
||||
func defaultPATServerDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Key: defaultPATProductID,
|
||||
DisplayName: defaultPATDisplayName,
|
||||
Endpoint: defaultPATMCPEndpoint(),
|
||||
CLI: market.CLIOverlay{
|
||||
ID: defaultPATProductID,
|
||||
Command: defaultPATProductID,
|
||||
Prefixes: []string{defaultPATProductID},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func defaultPATMCPEndpoint() string {
|
||||
return defaultPATGatewayBaseURL() + "/server/" + defaultPATServerID
|
||||
}
|
||||
|
||||
func defaultPATGatewayBaseURL() string {
|
||||
raw := strings.TrimSpace(authpkg.GetMCPBaseURL())
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return strings.TrimRight(raw, "/")
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
switch {
|
||||
case host == "mcp.dingtalk.com":
|
||||
host = "mcp-gw.dingtalk.com"
|
||||
case strings.HasPrefix(host, "pre-mcp."):
|
||||
host = strings.Replace(host, "pre-mcp.", "pre-mcp-gw.", 1)
|
||||
case strings.HasPrefix(host, "mcp."):
|
||||
host = strings.Replace(host, "mcp.", "mcp-gw.", 1)
|
||||
}
|
||||
|
||||
if port := parsed.Port(); port != "" {
|
||||
parsed.Host = net.JoinHostPort(host, port)
|
||||
} else {
|
||||
parsed.Host = host
|
||||
}
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return strings.TrimRight(parsed.String(), "/")
|
||||
}
|
||||
|
||||
// SetDynamicServers injects server data discovered from servers.json.
|
||||
// All product endpoints are resolved dynamically from this data.
|
||||
func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
@@ -48,6 +102,7 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
products := make(map[string]bool)
|
||||
aliases := make(map[string]string)
|
||||
toolEndpoints := make(map[string]string)
|
||||
registerDynamicServer(defaultPATServerDescriptor(), endpoints, products, aliases, toolEndpoints)
|
||||
for _, server := range servers {
|
||||
if server.CLI.Skip {
|
||||
continue
|
||||
@@ -102,6 +157,47 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
dynamicToolEndpoints = toolEndpoints
|
||||
}
|
||||
|
||||
func registerDynamicServer(server market.ServerDescriptor, endpoints map[string]string, products map[string]bool, aliases map[string]string, toolEndpoints map[string]string) {
|
||||
if server.CLI.Skip {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(server.CLI.ID)
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
if id != "" && endpoint != "" {
|
||||
endpoints[id] = endpoint
|
||||
products[id] = true
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
endpoints[cmd] = endpoint
|
||||
products[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias != "" && endpoint != "" {
|
||||
endpoints[alias] = endpoint
|
||||
products[alias] = true
|
||||
// Build alias -> CLI.ID mapping.
|
||||
aliases[alias] = id
|
||||
}
|
||||
}
|
||||
// Build tool -> endpoint mapping from CLI tools and overrides.
|
||||
if endpoint != "" {
|
||||
for _, tool := range server.CLI.Tools {
|
||||
toolName := strings.TrimSpace(tool.Name)
|
||||
if toolName != "" {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName != "" {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldUseDirectRuntime(invocation executor.Invocation) bool {
|
||||
if strings.TrimSpace(os.Getenv(cli.CatalogFixtureEnv)) != "" {
|
||||
return false
|
||||
@@ -131,14 +227,11 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
te := dynamicToolEndpoints
|
||||
dynamicMu.RUnlock()
|
||||
|
||||
// Priority 1: tool-level endpoint (resolves multi-endpoint products).
|
||||
if tool := strings.TrimSpace(toolName); tool != "" && te != nil {
|
||||
if endpoint, ok := te[tool]; ok {
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: product-level endpoint.
|
||||
// Priority 1: product-level endpoint.
|
||||
// When the caller already knows the productID (e.g. "drive"), the product
|
||||
// endpoint is authoritative. This prevents cross-product tool name
|
||||
// collisions (e.g. both "drive" and "doc" register "create_folder") from
|
||||
// routing the request to the wrong MCP server. See issue #219.
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
@@ -149,6 +242,24 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: tool-level endpoint (fallback for unknown productID).
|
||||
// This path is used when the caller does not know the productID but has a
|
||||
// tool name, e.g. in helper invocations or plugin routes where only the
|
||||
// tool name is available.
|
||||
if tool := strings.TrimSpace(toolName); tool != "" && te != nil {
|
||||
if endpoint, ok := te[tool]; ok {
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: built-in PAT fallback for cold-start paths that run before
|
||||
// discovery/plugin registration has populated the dynamic registry.
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if candidate == defaultPATProductID {
|
||||
return defaultPATMCPEndpoint(), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -158,7 +269,8 @@ func DirectRuntimeProductIDs() map[string]bool {
|
||||
dynamicMu.RLock()
|
||||
dp := dynamicProducts
|
||||
dynamicMu.RUnlock()
|
||||
ids := make(map[string]bool, len(dp))
|
||||
ids := make(map[string]bool, len(dp)+1)
|
||||
ids[defaultPATProductID] = true
|
||||
for key := range dp {
|
||||
ids[key] = true
|
||||
}
|
||||
|
||||
@@ -181,3 +181,114 @@ func TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint(t *testing.
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Issue #219 regression tests: cross-product tool name collision ---
|
||||
//
|
||||
// When two different products register tools with the same name (e.g. drive
|
||||
// and doc both have "create_folder"), the product-level endpoint must win
|
||||
// when the caller already knows the productID. Otherwise the tool-level map
|
||||
// (last-writer-wins) routes the invocation to the wrong MCP server.
|
||||
|
||||
const (
|
||||
testDriveEndpoint = "https://mcp-gw.dingtalk.com/server/drive-hash"
|
||||
testDocEndpoint = "https://mcp-gw.dingtalk.com/server/doc-hash"
|
||||
)
|
||||
|
||||
func driveDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testDriveEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "drive",
|
||||
Command: "drive",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"create_folder": {CLIName: "mkdir"},
|
||||
"list_files": {CLIName: "list"},
|
||||
"download_file": {CLIName: "download"},
|
||||
"get_upload_info": {CLIName: "upload-info"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func docDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testDocEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "doc",
|
||||
Command: "doc",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"create_folder": {CLIName: "create", Group: "folder"},
|
||||
"download_file": {CLIName: "download"},
|
||||
"search_documents": {CLIName: "search"},
|
||||
"list_nodes": {CLIName: "list"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel verifies
|
||||
// that when productID is known and has a registered endpoint, the product-level
|
||||
// endpoint is used even if the tool-level map points to a different server
|
||||
// (due to same-name tool collision). This is the core fix for issue #219.
|
||||
func TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
servers []market.ServerDescriptor
|
||||
}{
|
||||
{
|
||||
name: "drive first, doc second",
|
||||
servers: []market.ServerDescriptor{driveDescriptor(), docDescriptor()},
|
||||
},
|
||||
{
|
||||
name: "doc first, drive second",
|
||||
servers: []market.ServerDescriptor{docDescriptor(), driveDescriptor()},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
SetDynamicServers(tc.servers)
|
||||
|
||||
// Drive tools must always route to drive's endpoint regardless of
|
||||
// registration order — productID "drive" is known.
|
||||
assertEndpoint(t, "drive", "create_folder", testDriveEndpoint)
|
||||
assertEndpoint(t, "drive", "download_file", testDriveEndpoint)
|
||||
assertEndpoint(t, "drive", "list_files", testDriveEndpoint)
|
||||
assertEndpoint(t, "drive", "get_upload_info", testDriveEndpoint)
|
||||
|
||||
// Doc tools must always route to doc's endpoint.
|
||||
assertEndpoint(t, "doc", "create_folder", testDocEndpoint)
|
||||
assertEndpoint(t, "doc", "download_file", testDocEndpoint)
|
||||
assertEndpoint(t, "doc", "search_documents", testDocEndpoint)
|
||||
assertEndpoint(t, "doc", "list_nodes", testDocEndpoint)
|
||||
|
||||
// Product-level fallback (no tool name) still works.
|
||||
assertEndpoint(t, "drive", "", testDriveEndpoint)
|
||||
assertEndpoint(t, "doc", "", testDocEndpoint)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown verifies that
|
||||
// tool-level routing still works as a fallback when productID is empty or has
|
||||
// no registered endpoint (the original design intent for tool-level Priority 1).
|
||||
func TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
SetDynamicServers([]market.ServerDescriptor{driveDescriptor(), docDescriptor()})
|
||||
|
||||
// When productID is empty, tool-level endpoint is the only option.
|
||||
// The actual endpoint depends on registration order (last-writer-wins),
|
||||
// but the lookup must succeed.
|
||||
endpoint, ok := directRuntimeEndpoint("", "create_folder")
|
||||
if !ok {
|
||||
t.Fatal("directRuntimeEndpoint(\"\", \"create_folder\") returned ok=false, want ok=true")
|
||||
}
|
||||
if endpoint != testDriveEndpoint && endpoint != testDocEndpoint {
|
||||
t.Fatalf("directRuntimeEndpoint(\"\", \"create_folder\") = %q, want one of drive/doc endpoints", endpoint)
|
||||
}
|
||||
|
||||
// Unique tools (no collision) still resolve via tool-level.
|
||||
assertEndpoint(t, "", "search_documents", testDocEndpoint)
|
||||
assertEndpoint(t, "", "get_upload_info", testDriveEndpoint)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -429,10 +429,3 @@ func printPerfReportSummary(w io.Writer, report *PerfReport) {
|
||||
fmt.Fprintf(w, " %-25s ─────────\n", "─────────────────────────")
|
||||
fmt.Fprintf(w, " %-25s %dms (框架开销 %dms)\n", "总耗时", report.TotalMs, report.OverheadMs)
|
||||
}
|
||||
|
||||
func formatLocalTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
+11
-55
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/editionmerge"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
@@ -123,13 +124,10 @@ func injectStaticServers(servers []edition.ServerInfo) {
|
||||
// Tests may override discoveryBaseURLOverride to redirect to a local server;
|
||||
// in that case the registry cache is always bypassed.
|
||||
// editionPartition returns the cache partition for the active edition.
|
||||
// Each edition gets its own partition to prevent cross-edition data leakage.
|
||||
// Thin wrapper around config.EditionPartition; kept so the many existing
|
||||
// call sites in internal/app don't need to thread edition.Get() everywhere.
|
||||
func editionPartition() string {
|
||||
name := edition.Get().Name
|
||||
if name == "" || name == "open" {
|
||||
return config.DefaultPartition
|
||||
}
|
||||
return name + "/default"
|
||||
return config.EditionPartition(edition.Get().Name)
|
||||
}
|
||||
|
||||
// discoveryTraceEnabled reports whether the user asked for discovery-path diagnostics.
|
||||
@@ -265,8 +263,8 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
|
||||
if fn := edition.Get().FallbackServers; fn != nil {
|
||||
if fb := fn(); len(fb) > 0 {
|
||||
slog.Debug("loadDynamicCommands: using FallbackServers", "count", len(fb))
|
||||
descriptors := fallbackToDescriptors(fb)
|
||||
descriptors = mergeSupplementServers(descriptors)
|
||||
descriptors := editionmerge.FallbackToDescriptors(fb)
|
||||
descriptors = editionmerge.MergeSupplement(descriptors)
|
||||
SetDynamicServers(descriptors)
|
||||
return nil
|
||||
}
|
||||
@@ -275,7 +273,7 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
|
||||
}
|
||||
|
||||
// Merge edition-specific supplement servers (not in Market).
|
||||
servers = mergeSupplementServers(servers)
|
||||
servers = editionmerge.MergeSupplement(servers)
|
||||
// Inject dynamic server data for endpoint resolution
|
||||
SetDynamicServers(servers)
|
||||
|
||||
@@ -545,49 +543,7 @@ func mergeTopLevelCommands(commands []*cobra.Command) []*cobra.Command {
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeSupplementServers appends edition-specific servers (not in Market)
|
||||
// into the discovery result. Existing IDs from Market/cache take precedence.
|
||||
func mergeSupplementServers(servers []market.ServerDescriptor) []market.ServerDescriptor {
|
||||
fn := edition.Get().SupplementServers
|
||||
if fn == nil {
|
||||
return servers
|
||||
}
|
||||
existing := make(map[string]bool, len(servers))
|
||||
for _, s := range servers {
|
||||
existing[s.CLI.ID] = true
|
||||
existing[s.Key] = true
|
||||
}
|
||||
for _, sup := range fn() {
|
||||
if !existing[sup.ID] {
|
||||
servers = append(servers, market.ServerDescriptor{
|
||||
Key: sup.ID,
|
||||
DisplayName: sup.Name,
|
||||
Endpoint: sup.Endpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: sup.ID,
|
||||
Command: sup.ID,
|
||||
Prefixes: sup.Prefixes,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
// fallbackToDescriptors converts edition.ServerInfo into market.ServerDescriptor.
|
||||
func fallbackToDescriptors(servers []edition.ServerInfo) []market.ServerDescriptor {
|
||||
descriptors := make([]market.ServerDescriptor, 0, len(servers))
|
||||
for _, s := range servers {
|
||||
descriptors = append(descriptors, market.ServerDescriptor{
|
||||
Key: s.ID,
|
||||
DisplayName: s.Name,
|
||||
Endpoint: s.Endpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: s.ID,
|
||||
Command: s.ID,
|
||||
Prefixes: s.Prefixes,
|
||||
},
|
||||
})
|
||||
}
|
||||
return descriptors
|
||||
}
|
||||
// mergeSupplementServers / fallbackToDescriptors have moved to
|
||||
// internal/editionmerge so that both internal/cli and internal/app can
|
||||
// apply the edition's SupplementServers / FallbackServers hooks against
|
||||
// the same discovery pipeline (command tree + runtime catalog).
|
||||
|
||||
@@ -16,6 +16,7 @@ package app
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -143,6 +144,64 @@ func TestPickCommands_EmptyDynamicPreservesHelpers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_HelperGroupShadowsDynamicLeaf simulates the issue #164
|
||||
// shape mismatch: the discovery envelope publishes `chat group members` as
|
||||
// a LEAF (the get_group_members tool exposed at that CLI path), while the
|
||||
// hardcoded helper has restructured `members` into a GROUP container with
|
||||
// `list / add / remove / add-bot` subcommands. The helper group carries the
|
||||
// preferLegacyLeaf priority annotation, so it must replace the dynamic leaf
|
||||
// and surface its subtree — otherwise `dws chat group members list` is
|
||||
// unreachable and the user-visible regression in #164 stays.
|
||||
func TestPickCommands_HelperGroupShadowsDynamicLeaf(t *testing.T) {
|
||||
dynMembers := &cobra.Command{Use: "members", Run: func(*cobra.Command, []string) {}}
|
||||
dynMembers.Flags().String("id", "", "")
|
||||
dynGroup := &cobra.Command{Use: "group"}
|
||||
dynGroup.AddCommand(dynMembers)
|
||||
dyn := &cobra.Command{Use: "chat"}
|
||||
dyn.AddCommand(dynGroup)
|
||||
|
||||
hlpList := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
|
||||
hlpList.Flags().String("id", "", "")
|
||||
hlpAdd := &cobra.Command{Use: "add", Run: func(*cobra.Command, []string) {}}
|
||||
hlpRemove := &cobra.Command{Use: "remove", Run: func(*cobra.Command, []string) {}}
|
||||
hlpMembers := &cobra.Command{Use: "members"}
|
||||
hlpMembers.AddCommand(hlpList, hlpAdd, hlpRemove)
|
||||
cobracmd.SetOverridePriority(hlpMembers, 100)
|
||||
hlpGroup := &cobra.Command{Use: "group"}
|
||||
hlpGroup.AddCommand(hlpMembers)
|
||||
hlp := &cobra.Command{Use: "chat"}
|
||||
hlp.AddCommand(hlpGroup)
|
||||
|
||||
got := pickCommands([]*cobra.Command{dyn}, []*cobra.Command{hlp})
|
||||
if len(got) != 1 || got[0] != dyn {
|
||||
t.Fatalf("got %v, want [dyn]", got)
|
||||
}
|
||||
|
||||
// Locate the (potentially replaced) members node under chat.group.
|
||||
var members *cobra.Command
|
||||
for _, c := range dynGroup.Commands() {
|
||||
if c.Name() == "members" {
|
||||
members = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if members == nil {
|
||||
t.Fatalf("members node missing under dyn.chat.group after merge")
|
||||
}
|
||||
|
||||
want := map[string]bool{"list": false, "add": false, "remove": false}
|
||||
for _, sub := range members.Commands() {
|
||||
if _, ok := want[sub.Name()]; ok {
|
||||
want[sub.Name()] = true
|
||||
}
|
||||
}
|
||||
for name, seen := range want {
|
||||
if !seen {
|
||||
t.Errorf("expected `chat group members %s` after merge, missing", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_NilsAreSkipped guards against nil entries sneaking in from
|
||||
// a misbehaving factory.
|
||||
func TestPickCommands_NilsAreSkipped(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// TestEditionPartition_SingleSourceOfTruth is the regression test that
|
||||
// specifically targets the original bug: internal/app.loadDynamicCommands
|
||||
// was computing its partition one way (editionPartition() →
|
||||
// "wukong/default") while internal/cli.EnvironmentLoader was hardcoding
|
||||
// config.DefaultPartition ("default/default"). This meant runtime endpoint
|
||||
// resolution and command-tree generation read different cache files, and
|
||||
// under gray-release the two partitions carried disjoint product lists —
|
||||
// the historical root cause of `dws conference meeting create` failing
|
||||
// while `dws todo task list` succeeded on the same host.
|
||||
//
|
||||
// Keeping both sides funneled through config.EditionPartition is the
|
||||
// central invariant the fix enforces. If this test ever regresses, the
|
||||
// two-partition split almost certainly came back.
|
||||
func TestEditionPartition_SingleSourceOfTruth(t *testing.T) {
|
||||
t.Cleanup(func() { edition.Override(&edition.Hooks{}) })
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
edition string
|
||||
want string
|
||||
}{
|
||||
{"open edition falls through to default/default", "", config.DefaultPartition},
|
||||
{"explicit open edition remains default", "open", config.DefaultPartition},
|
||||
{"wukong overlay uses wukong/default", "wukong", "wukong/default"},
|
||||
{"custom edition is namespaced", "internal-lab", "internal-lab/default"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
edition.Override(&edition.Hooks{Name: tc.edition})
|
||||
legacy := editionPartition()
|
||||
shared := config.EditionPartition(edition.Get().Name)
|
||||
|
||||
if legacy != shared {
|
||||
t.Fatalf("editionPartition()=%q, config.EditionPartition()=%q — partition split regressed for edition %q", legacy, shared, tc.edition)
|
||||
}
|
||||
if legacy != tc.want {
|
||||
t.Fatalf("editionPartition()=%q, want %q for edition %q", legacy, tc.want, tc.edition)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+190
-32
@@ -32,6 +32,7 @@ import (
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
@@ -43,8 +44,12 @@ const (
|
||||
// PatAuthPollInterval is how often we poll to check if the user has
|
||||
// completed authorization.
|
||||
PatAuthPollInterval = 5 * time.Second
|
||||
|
||||
patScopeAuthRequiredCode = "PAT_SCOPE_AUTH_REQUIRED"
|
||||
)
|
||||
|
||||
var openBrowserFunc = tryOpenBrowser
|
||||
|
||||
// PatScopeError holds information about a missing PAT scope.
|
||||
type PatScopeError struct {
|
||||
OriginalError string
|
||||
@@ -182,21 +187,50 @@ func PrintPatAuthError(w io.Writer, scopeErr *PatScopeError) {
|
||||
|
||||
// PrintPatAuthJSON prints a machine-readable PAT authorization error.
|
||||
func PrintPatAuthJSON(w io.Writer, scopeErr *PatScopeError) {
|
||||
payload := map[string]any{
|
||||
"ok": false,
|
||||
"identity": scopeErr.Identity,
|
||||
"error": map[string]any{
|
||||
"type": scopeErr.ErrorType,
|
||||
"message": scopeErr.Message,
|
||||
"hint": scopeErr.Hint,
|
||||
},
|
||||
fmt.Fprintln(w, buildPATScopeJSON(scopeErr, authpkg.HostOwnsPATFlow()))
|
||||
}
|
||||
|
||||
func wantsStructuredPATOutput(r *runtimeRunner) bool {
|
||||
if r == nil || r.globalFlags == nil {
|
||||
return false
|
||||
}
|
||||
if scopeErr.MissingScope != "" {
|
||||
payload["missing_scope"] = scopeErr.MissingScope
|
||||
return strings.EqualFold(strings.TrimSpace(r.globalFlags.Format), "json")
|
||||
}
|
||||
|
||||
func wantsStructuredPATOutputFromRunner(runner executor.Runner) bool {
|
||||
rr, ok := runner.(*runtimeRunner)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return wantsStructuredPATOutput(rr)
|
||||
}
|
||||
|
||||
func currentPATOpenBrowser(configDir string) bool {
|
||||
return pat.EffectiveOpenBrowser(configDir)
|
||||
}
|
||||
|
||||
func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(payload, "", " ")
|
||||
fmt.Fprintln(w, string(data))
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return raw
|
||||
}
|
||||
|
||||
data, ok := payload["data"].(map[string]any)
|
||||
if !ok || data == nil {
|
||||
data = map[string]any{}
|
||||
payload["data"] = data
|
||||
}
|
||||
data["openBrowser"] = openBrowser
|
||||
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
// WaitForPatAuthorization polls until the user completes authorization or timeout.
|
||||
@@ -258,6 +292,19 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
|
||||
// retryWithPatAuthRetry wraps an invocation that failed with a PAT scope error.
|
||||
// It waits for the user to complete authorization and then retries the invocation.
|
||||
func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocation executor.Invocation, scopeErr *PatScopeError, configDir string, output io.Writer) (executor.Result, error) {
|
||||
hostOwnedPAT := authpkg.HostOwnsPATFlow()
|
||||
slog.Debug("pat.host_owned_decision",
|
||||
"site", "retryWithPatAuthRetry",
|
||||
"hostOwned", hostOwnedPAT,
|
||||
"agentCodeEnvSet", os.Getenv(authpkg.AgentCodeEnv) != "",
|
||||
)
|
||||
if hostOwnedPAT {
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: buildPATScopeJSON(scopeErr, true)}
|
||||
}
|
||||
if wantsStructuredPATOutputFromRunner(runner) {
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: buildPATScopeJSON(scopeErr, false)}
|
||||
}
|
||||
|
||||
// Print the PAT error in human-readable format
|
||||
PrintPatAuthError(output, scopeErr)
|
||||
|
||||
@@ -305,6 +352,32 @@ func IsPatRetrying(ctx context.Context) bool {
|
||||
return v
|
||||
}
|
||||
|
||||
func openPATAuthorizationURI(rawURI string) error {
|
||||
if rawURI == "" {
|
||||
// Defensive guard for future callers. The current call site already
|
||||
// checks for a non-empty PAT URI before invoking this helper.
|
||||
return nil
|
||||
}
|
||||
// The PAT service returns the complete authorization URL. Treat it as an
|
||||
// opaque string and open it verbatim instead of parsing/rebuilding it
|
||||
// locally, because required parameters may live in query, hash, or
|
||||
// fragment sections.
|
||||
return openBrowserFunc(rawURI)
|
||||
}
|
||||
|
||||
func printPATPollDebugResponse(output io.Writer, statusCode int, body []byte) {
|
||||
if os.Getenv("DWS_DEBUG_PAT_POLL") == "" {
|
||||
return
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if trimmed == "" {
|
||||
trimmed = "<empty body>"
|
||||
}
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintf(output, " ℹ PAT 轮询接口返回原文 (HTTP %d):\n", statusCode)
|
||||
fmt.Fprintf(output, " %s\n", trimmed)
|
||||
}
|
||||
|
||||
// handlePatAuthCheck is called by runner.executeInvocation when a PAT
|
||||
// authorization error is detected. It injects the server-assigned clientId
|
||||
// as x-robot-uid header, prints authorization details, opens the browser,
|
||||
@@ -338,9 +411,17 @@ func handlePatAuthCheck(
|
||||
"flowId", patData.Data.FlowID,
|
||||
"hasSecret", patData.Data.ClientSecret != "",
|
||||
)
|
||||
hostOwnedPAT := authpkg.HostOwnsPATFlow()
|
||||
openBrowser := currentPATOpenBrowser(configDir)
|
||||
slog.Debug("pat.host_owned_decision",
|
||||
"site", "handlePatAuthCheck",
|
||||
"hostOwned", hostOwnedPAT,
|
||||
"agentCodeEnvSet", os.Getenv(authpkg.AgentCodeEnv) != "",
|
||||
)
|
||||
|
||||
// Inject clientId/clientSecret from PAT response as runtime credentials
|
||||
// so that subsequent device flow auth uses the server-assigned app identity.
|
||||
var appCfg *authpkg.AppConfig
|
||||
if patData.Data.ClientID != "" {
|
||||
if patData.Data.ClientSecret != "" {
|
||||
// When both clientId and clientSecret are provided, use direct mode
|
||||
@@ -353,19 +434,32 @@ func handlePatAuthCheck(
|
||||
authpkg.SetClientIDFromMCP(patData.Data.ClientID)
|
||||
}
|
||||
|
||||
// Persist clientId (and optionally secret) to ~/.dws/app.json so that
|
||||
// future process invocations can load it at startup and populate
|
||||
// DWS_CLIENT_ID env before the first MCP request.
|
||||
appCfg := &authpkg.AppConfig{
|
||||
ClientID: patData.Data.ClientID,
|
||||
}
|
||||
// Persist only after an explicit APPROVED result below. Raw PAT
|
||||
// interceptions (host-owned / json / empty-flow pass-through) must not
|
||||
// rewrite the shared ~/.dws/app.json state for unrelated shells or agents.
|
||||
appCfg = &authpkg.AppConfig{ClientID: patData.Data.ClientID}
|
||||
if patData.Data.ClientSecret != "" {
|
||||
appCfg.ClientSecret = authpkg.PlainSecret(patData.Data.ClientSecret)
|
||||
}
|
||||
if err := authpkg.SaveAppConfig(configDir, appCfg); err != nil {
|
||||
slog.Warn("failed to persist app config from PAT", "error", err)
|
||||
fmt.Fprintf(output, " \u26a0 保存应用配置失败: %v (下次启动可能需要重新授权)\n", err)
|
||||
}
|
||||
|
||||
// In host-controlled PAT mode (driven solely by DINGTALK_DWS_AGENTCODE),
|
||||
// or when flowId is absent, the CLI returns machine-readable JSON to
|
||||
// stderr and leaves UI/polling/retry to the host. `claw-type` is NOT
|
||||
// used for this decision — it is only forwarded on the wire via
|
||||
// edition.MergeHeaders and surfaced in hostControl for traceability.
|
||||
if hostOwnedPAT || patData.Data.FlowID == "" {
|
||||
if hostOwnedPAT {
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorForHostControl(patErr.RawJSON)}
|
||||
}
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, openBrowser)}
|
||||
}
|
||||
|
||||
if wantsStructuredPATOutput(r) {
|
||||
if openBrowser && patData.Data.URI != "" {
|
||||
_ = openBrowserFunc(patData.Data.URI)
|
||||
}
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, openBrowser)}
|
||||
}
|
||||
|
||||
bold := color.New(color.Bold).SprintFunc()
|
||||
@@ -382,14 +476,9 @@ func handlePatAuthCheck(
|
||||
}
|
||||
if patData.Data.URI != "" {
|
||||
fmt.Fprintf(output, " %s %s\n\n", dim("🔗"), cyan(patData.Data.URI))
|
||||
// Best-effort browser open.
|
||||
_ = tryOpenBrowser(patData.Data.URI)
|
||||
}
|
||||
|
||||
// If no flowId, we can't poll — fall back to returning PATError for host-app.
|
||||
if patData.Data.FlowID == "" {
|
||||
fmt.Fprintln(output)
|
||||
return executor.Result{}, patErr
|
||||
if openBrowser {
|
||||
_ = openPATAuthorizationURI(patData.Data.URI)
|
||||
}
|
||||
}
|
||||
|
||||
// Poll the device flow status until user authorizes, rejects, or timeout.
|
||||
@@ -411,6 +500,13 @@ func handlePatAuthCheck(
|
||||
fmt.Fprintf(output, "%s %s\n", greenFn("✓"), bold("授权成功!"))
|
||||
fmt.Fprintln(output)
|
||||
|
||||
if appCfg != nil {
|
||||
if err := authpkg.SaveAppConfig(configDir, appCfg); err != nil {
|
||||
slog.Warn("failed to persist approved app config from PAT", "error", err)
|
||||
fmt.Fprintf(output, " \u26a0 保存应用配置失败: %v (下次启动可能需要重新授权)\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Exchange authCode for a fresh access token (mirrors device_flow loginOnce).
|
||||
if authCode != "" {
|
||||
slog.Debug("PAT retry: exchanging authCode for token", "hasCode", true)
|
||||
@@ -475,6 +571,66 @@ func handlePatAuthCheck(
|
||||
}
|
||||
}
|
||||
|
||||
func enrichPATErrorForHostControl(raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return raw
|
||||
}
|
||||
|
||||
// Route back through the classifier so host-owned active retry emits the
|
||||
// exact same PAT JSON shape as passive classification.
|
||||
if patErr := apperrors.ClassifyPatAuthCheck(payload); patErr != nil {
|
||||
return patErr.RawJSON
|
||||
}
|
||||
|
||||
apperrors.ApplyHostMutations(payload)
|
||||
|
||||
// stderr JSON MUST be single-line.
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
// buildPATScopeJSON renders the PAT_SCOPE_AUTH_REQUIRED stderr payload.
|
||||
// includeHostControl=true follows the standard host-owned/CLI-owned split
|
||||
// (data.hostControl is injected only if HostControlBlock is non-nil).
|
||||
// includeHostControl=false is an explicit override used by the CLI-owned
|
||||
// branch so that any env-mode misconfiguration cannot leak a host-owned
|
||||
// contract into stderr.
|
||||
func buildPATScopeJSON(scopeErr *PatScopeError, includeHostControl bool) string {
|
||||
data := map[string]any{
|
||||
"identity": scopeErr.Identity,
|
||||
"errorType": scopeErr.ErrorType,
|
||||
"message": scopeErr.Message,
|
||||
"hint": scopeErr.Hint,
|
||||
"missingScope": scopeErr.MissingScope,
|
||||
"openBrowser": apperrors.PATOpenBrowserValue(),
|
||||
}
|
||||
if includeHostControl {
|
||||
if hostControl := apperrors.HostControlBlock(); hostControl != nil {
|
||||
data["hostControl"] = hostControl
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"success": false,
|
||||
"code": patScopeAuthRequiredCode,
|
||||
"data": data,
|
||||
}
|
||||
// stderr JSON MUST be single-line.
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED"}`
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// pollPatDeviceFlow polls the PAT device flow status endpoint until a terminal
|
||||
// state (APPROVED/REJECTED/EXPIRED) is reached or the context is cancelled.
|
||||
// Returns the final status string and the authCode (non-empty only on APPROVED).
|
||||
@@ -537,23 +693,25 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out
|
||||
var pollResp authpkg.DevicePollResponse
|
||||
if err := json.Unmarshal(bodyBytes, &pollResp); err != nil {
|
||||
slog.Debug("PAT poll: failed to parse response", "error", err, "body", string(bodyBytes))
|
||||
printPATPollDebugResponse(output, resp.StatusCode, bodyBytes)
|
||||
continue
|
||||
}
|
||||
|
||||
status := authpkg.ParseDeviceFlowStatus(pollResp.Data.Status, pollResp.Success)
|
||||
pollData := pollResp.EffectiveData()
|
||||
status := authpkg.ParseDeviceFlowStatus(pollData.Status, pollResp.Success)
|
||||
switch status {
|
||||
case authpkg.StatusApproved:
|
||||
fmt.Fprintln(output) // clear the polling line
|
||||
return status, pollResp.Data.AuthCode, nil
|
||||
return status, pollData.AuthCode, nil
|
||||
case authpkg.StatusRejected, authpkg.StatusExpired:
|
||||
fmt.Fprintln(output) // clear the polling line
|
||||
return status, "", nil
|
||||
case authpkg.StatusPending:
|
||||
// keep polling
|
||||
default:
|
||||
// ParseDeviceFlowStatus normalizes empty+!success to EXPIRED,
|
||||
// so this branch handles truly unknown statuses.
|
||||
fmt.Fprintln(output)
|
||||
printPATPollDebugResponse(output, resp.StatusCode, bodyBytes)
|
||||
return status, "", nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
)
|
||||
|
||||
func TestIsPatScopeError_MissingScope(t *testing.T) {
|
||||
@@ -86,21 +87,6 @@ func TestIsPatScopeError_InsufficientScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPatScopeError_MissingScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := apperrors.NewAuth("missing required scope(s): mail:user_mailbox.message:send")
|
||||
scopeErr := extractPatScopeError(err)
|
||||
if scopeErr == nil {
|
||||
t.Fatal("expected non-nil PatScopeError")
|
||||
}
|
||||
if scopeErr.ErrorType != "missing_scope" {
|
||||
t.Errorf("expected error type 'missing_scope', got %q", scopeErr.ErrorType)
|
||||
}
|
||||
if !strings.Contains(scopeErr.Hint, "dws auth login") {
|
||||
t.Errorf("expected hint to contain 'dws auth login', got %q", scopeErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPatScopeError_ExtractsScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := &PatScopeError{
|
||||
@@ -142,7 +128,7 @@ func TestPrintPatAuthError_HumanReadable(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPrintPatAuthJSON_MachineReadable(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
var buf strings.Builder
|
||||
scopeErr := &PatScopeError{
|
||||
Identity: "user",
|
||||
@@ -153,12 +139,28 @@ func TestPrintPatAuthJSON_MachineReadable(t *testing.T) {
|
||||
}
|
||||
PrintPatAuthJSON(&buf, scopeErr)
|
||||
|
||||
// The payload is required to be single-line; assert by parsing the JSON
|
||||
// rather than by matching pretty-printed substrings.
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, `"ok": false`) {
|
||||
t.Errorf("expected JSON to contain ok: false, got: %s", output)
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(output), &parsed); err != nil {
|
||||
t.Fatalf("PrintPatAuthJSON must emit directly-parsable JSON: %v\nraw=%s", err, output)
|
||||
}
|
||||
if !strings.Contains(output, `"missing_scope": "mail:send"`) {
|
||||
t.Errorf("expected JSON to contain missing_scope, got: %s", output)
|
||||
if code, _ := parsed["code"].(string); code != "PAT_SCOPE_AUTH_REQUIRED" {
|
||||
t.Errorf("code = %q, want PAT_SCOPE_AUTH_REQUIRED", code)
|
||||
}
|
||||
data, _ := parsed["data"].(map[string]any)
|
||||
if data == nil {
|
||||
t.Fatalf("expected data object, got: %s", output)
|
||||
}
|
||||
if got, _ := data["missingScope"].(string); got != "mail:send" {
|
||||
t.Errorf("missingScope = %q, want mail:send", got)
|
||||
}
|
||||
if got, ok := data["openBrowser"].(bool); !ok || !got {
|
||||
t.Errorf("openBrowser = %#v, want true", data["openBrowser"])
|
||||
}
|
||||
if _, ok := data["hostControl"]; ok {
|
||||
t.Errorf("unexpected data.hostControl in CLI-owned JSON output: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,6 +413,84 @@ func TestPollPatDeviceFlow_RedirectSkipped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollPatDeviceFlow_UnknownStatusPrintsRawResponse(t *testing.T) {
|
||||
t.Setenv("DWS_DEBUG_PAT_POLL", "1")
|
||||
server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{
|
||||
{Success: true, Data: authpkg.DevicePollData{Status: ""}},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
status, authCode, err := pollPatDeviceFlow(ctx, "flow-unknown", configDir, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status != "" {
|
||||
t.Fatalf("expected empty unknown status, got %q", status)
|
||||
}
|
||||
if authCode != "" {
|
||||
t.Fatalf("expected empty authCode for unknown status, got %q", authCode)
|
||||
}
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "PAT 轮询接口返回原文") {
|
||||
t.Fatalf("expected raw poll response to be printed, got %q", output)
|
||||
}
|
||||
if !strings.Contains(output, `"status":""`) {
|
||||
t.Fatalf("expected raw poll body in output, got %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollPatDeviceFlow_UnknownStatusHidesRawResponseByDefault(t *testing.T) {
|
||||
server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{
|
||||
{Success: true, Data: authpkg.DevicePollData{Status: ""}},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
status, authCode, err := pollPatDeviceFlow(ctx, "flow-unknown-default", configDir, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status != "" {
|
||||
t.Fatalf("expected empty unknown status, got %q", status)
|
||||
}
|
||||
if authCode != "" {
|
||||
t.Fatalf("expected empty authCode for unknown status, got %q", authCode)
|
||||
}
|
||||
output := buf.String()
|
||||
if strings.Contains(output, "PAT 轮询接口返回原文") {
|
||||
t.Fatalf("expected raw poll response to stay hidden by default, got %q", output)
|
||||
}
|
||||
}
|
||||
func TestPollPatDeviceFlow_ResultEnvelopeCompatibility(t *testing.T) {
|
||||
server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{
|
||||
{Success: true, Result: authpkg.DevicePollData{Status: "PENDING"}},
|
||||
{Success: true, Result: authpkg.DevicePollData{Status: "APPROVED", AuthCode: "code-from-result"}},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
status, authCode, err := pollPatDeviceFlow(ctx, "flow-result", configDir, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status != "APPROVED" {
|
||||
t.Fatalf("expected APPROVED, got %q", status)
|
||||
}
|
||||
if authCode != "code-from-result" {
|
||||
t.Fatalf("expected authCode from result envelope, got %q", authCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractPatScopeError edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -484,6 +564,10 @@ func setupHandlePATServer(t *testing.T, terminalStatus string, authCode string)
|
||||
}
|
||||
|
||||
func makePATErrorJSON(flowID, clientID string) string {
|
||||
return makePATErrorJSONWithURI(flowID, clientID, "")
|
||||
}
|
||||
|
||||
func makePATErrorJSONWithURI(flowID, clientID, uri string) string {
|
||||
type patData struct {
|
||||
Desc string `json:"desc"`
|
||||
FlowID string `json:"flowId"`
|
||||
@@ -498,7 +582,7 @@ func makePATErrorJSON(flowID, clientID string) string {
|
||||
Data: patData{
|
||||
Desc: "test auth",
|
||||
FlowID: flowID,
|
||||
URI: "", // empty to avoid opening browser in test
|
||||
URI: uri,
|
||||
ClientID: clientID,
|
||||
},
|
||||
}
|
||||
@@ -507,6 +591,7 @@ func makePATErrorJSON(flowID, clientID string) string {
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
|
||||
defer server.Close()
|
||||
|
||||
@@ -539,6 +624,9 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
if !retryHasKey {
|
||||
t.Fatal("expected retry context to have patRetryingKey")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, "app.json")); err != nil {
|
||||
t.Fatalf("expected approved PAT flow to persist app.json, stat error = %v", err)
|
||||
}
|
||||
// Verify SetClientIDFromMCP was called with the PAT response clientId.
|
||||
if cid := authpkg.ClientID(); cid != "test-client-id" {
|
||||
t.Errorf("expected ClientID 'test-client-id', got %q", cid)
|
||||
@@ -546,6 +634,7 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_Rejected(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, configDir := setupHandlePATServer(t, "REJECTED", "")
|
||||
defer server.Close()
|
||||
|
||||
@@ -574,7 +663,132 @@ func TestHandlePatAuthCheck_Rejected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", tmpDir)
|
||||
// Host-owned decision: driven ONLY by DINGTALK_DWS_AGENTCODE.
|
||||
// DINGTALK_AGENT is set to demonstrate it does NOT leak into
|
||||
// hostControl.clawType — the open-source build pins that to the
|
||||
// literal edition.DefaultOSSClawType value ("openClaw").
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
|
||||
t.Setenv("DINGTALK_AGENT", "sales-copilot")
|
||||
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
t.Fatal("runner should not be called in host-controlled PAT mode")
|
||||
return executor.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{fallback: mock}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("flow-host", "test-client-id")}
|
||||
|
||||
ctx := context.Background()
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(ctx, runner, executor.Invocation{
|
||||
CanonicalProduct: "test",
|
||||
Tool: "test_tool",
|
||||
}, patErr, tmpDir, &buf)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected PATError in host-controlled mode")
|
||||
}
|
||||
patOut, ok := err.(*apperrors.PATError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in host mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("host-owned PAT must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(host PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
|
||||
}
|
||||
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, _ := data["flowId"].(string); got != "flow-host" {
|
||||
t.Fatalf("data.flowId = %q, want flow-host", got)
|
||||
}
|
||||
hostControl, _ := data["hostControl"].(map[string]any)
|
||||
if got, _ := hostControl["clawType"].(string); got != "openClaw" {
|
||||
t.Fatalf("hostControl.clawType = %q, want openClaw (hard-wired by open-source edition)", got)
|
||||
}
|
||||
if got, _ := hostControl["callbackOwner"].(string); got != "host" {
|
||||
t.Fatalf("hostControl.callbackOwner = %q, want host", got)
|
||||
}
|
||||
if _, ok := data["callbacks"]; ok {
|
||||
t.Fatalf("unexpected callbacks contract in host-controlled PAT payload: %#v", data["callbacks"])
|
||||
}
|
||||
if _, ok := payload["_meta"]; ok {
|
||||
t.Fatalf("unexpected _meta contract in host-controlled PAT payload: %#v", payload["_meta"])
|
||||
}
|
||||
if strings.Contains(patOut.RawJSON, `"pat","callback"`) {
|
||||
t.Fatalf("host PAT payload should not advertise dws pat callback argv: %s", patOut.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_HostControlledEmptyFlowID_StillReturnsContract(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", tmpDir)
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agt-support")
|
||||
t.Setenv("DINGTALK_AGENT", "customer-support")
|
||||
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
t.Fatal("runner should not be called in host-controlled PAT mode")
|
||||
return executor.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{fallback: mock}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("", "test-client-id")}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
|
||||
CanonicalProduct: "test",
|
||||
Tool: "test_tool",
|
||||
}, patErr, tmpDir, &buf)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected PATError in host-controlled mode")
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in host mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("host-owned PAT must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
patOut, ok := err.(*apperrors.PATError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(host PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
hostControl, _ := data["hostControl"].(map[string]any)
|
||||
if got, _ := hostControl["callbackOwner"].(string); got != "host" {
|
||||
t.Fatalf("hostControl.callbackOwner = %q, want host", got)
|
||||
}
|
||||
if _, ok := data["callbacks"]; ok {
|
||||
t.Fatalf("unexpected callbacks contract when flowId is absent: %#v", data["callbacks"])
|
||||
}
|
||||
if _, ok := payload["_meta"]; ok {
|
||||
t.Fatalf("unexpected _meta contract when flowId is absent: %#v", payload["_meta"])
|
||||
}
|
||||
if strings.Contains(patOut.RawJSON, `"pat","callback"`) {
|
||||
t.Fatalf("host PAT payload should not advertise dws pat callback argv: %s", patOut.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_EmptyFlowID_FallsBackToPATError(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
// No poll server needed — empty flowId means no polling, return PATError directly.
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", tmpDir)
|
||||
@@ -603,4 +817,376 @@ func TestHandlePatAuthCheck_EmptyFlowID_FallsBackToPATError(t *testing.T) {
|
||||
if _, ok := err.(*apperrors.PATError); !ok {
|
||||
t.Errorf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output for raw PAT passthrough, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_JSONModeReturnsStructuredPATErrorWithoutRetry(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", tmpDir)
|
||||
if _, err := pat.SetBrowserPolicy(tmpDir, "", false); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
t.Fatal("runner should not be called in json PAT mode")
|
||||
return executor.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{
|
||||
fallback: mock,
|
||||
globalFlags: &GlobalFlags{Format: "json"},
|
||||
}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("flow-json", "test-client-id")}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
|
||||
CanonicalProduct: "test",
|
||||
Tool: "test_tool",
|
||||
}, patErr, tmpDir, &buf)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected PATError in json PAT mode")
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in json PAT mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
|
||||
patOut, ok := err.(*apperrors.PATError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(json PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, ok := data["openBrowser"].(bool); !ok || got {
|
||||
t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"])
|
||||
}
|
||||
if _, ok := data["hostControl"]; ok {
|
||||
t.Fatalf("unexpected data.hostControl in CLI-owned json PAT mode: %s", patOut.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", tmpDir)
|
||||
if _, err := pat.SetBrowserPolicy(tmpDir, "", true); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
var opened string
|
||||
origOpenBrowser := openBrowserFunc
|
||||
openBrowserFunc = func(rawURL string) error {
|
||||
opened = rawURL
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
|
||||
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
t.Fatal("runner should not be called in json PAT mode")
|
||||
return executor.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{
|
||||
fallback: mock,
|
||||
globalFlags: &GlobalFlags{Format: "json"},
|
||||
}
|
||||
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-json","uri":"https://example.com/pat","clientId":"test-client-id"}}`
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
|
||||
CanonicalProduct: "test",
|
||||
Tool: "test_tool",
|
||||
}, &apperrors.PATError{RawJSON: raw}, tmpDir, &buf)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected PATError in json PAT mode")
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in json PAT mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
if opened != "https://example.com/pat" {
|
||||
t.Fatalf("opened url = %q, want https://example.com/pat", opened)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
|
||||
defer server.Close()
|
||||
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
var opened bool
|
||||
origOpenBrowser := openBrowserFunc
|
||||
openBrowserFunc = func(rawURL string) error {
|
||||
opened = true
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
|
||||
|
||||
var retryCalled bool
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
retryCalled = true
|
||||
return executor.Result{Response: map[string]any{"ok": true}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{
|
||||
fallback: mock,
|
||||
globalFlags: &GlobalFlags{Format: "table"},
|
||||
}
|
||||
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-approved","uri":"https://example.com/pat","clientId":"test-client-id"}}`
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
|
||||
CanonicalProduct: "test",
|
||||
Tool: "test_tool",
|
||||
}, &apperrors.PATError{RawJSON: raw}, configDir, &buf)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !retryCalled {
|
||||
t.Fatal("expected retry to still happen in non-json mode")
|
||||
}
|
||||
if opened {
|
||||
t.Fatal("browser should not open when policy disables it")
|
||||
}
|
||||
if !strings.Contains(buf.String(), "需要 PAT 授权") {
|
||||
t.Fatalf("expected human-readable PAT output, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithPatAuthRetry_JSONModeReturnsStructuredPATError(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
t.Fatal("runner should not be called in scope json mode")
|
||||
return executor.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{
|
||||
fallback: mock,
|
||||
globalFlags: &GlobalFlags{Format: "json"},
|
||||
}
|
||||
scopeErr := &PatScopeError{
|
||||
OriginalError: "missing required scope(s): mail:send",
|
||||
Identity: "user",
|
||||
ErrorType: "missing_scope",
|
||||
Message: "missing required scope(s): mail:send",
|
||||
Hint: "run `dws auth login --scope \"mail:send\"` to authorize the missing scope",
|
||||
MissingScope: "mail:send",
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := retryWithPatAuthRetry(context.Background(), runner, executor.Invocation{}, scopeErr, configDir, &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected PATError")
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in json scope mode, got %q", got)
|
||||
}
|
||||
patOut, ok := err.(*apperrors.PATError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(scope PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, ok := data["openBrowser"].(bool); !ok || got {
|
||||
t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"])
|
||||
}
|
||||
if _, ok := data["hostControl"]; ok {
|
||||
t.Fatalf("unexpected data.hostControl in CLI-owned json scope mode: %s", patOut.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichPATErrorForHostControl_SingleLineOutput locks in the wire
|
||||
// invariant: the enriched host-controlled PAT payload must be single-line
|
||||
// (no embedded newlines, no indentation), so stderr-line-scanning hosts
|
||||
// stay correct. Regression guard against accidental reintroduction of
|
||||
// json.MarshalIndent.
|
||||
func TestEnrichPATErrorForHostControl_SingleLineOutput(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
|
||||
t.Setenv("DINGTALK_AGENT", "sales-copilot")
|
||||
|
||||
raw := `{"success":false,"code":"PAT_LOW_RISK_NO_PERMISSION","data":{"flowId":"flow-1","desc":"授权","callbacks":["cb1","cb2"]}}`
|
||||
out := enrichPATErrorForHostControl(raw)
|
||||
|
||||
if strings.Contains(out, "\n") {
|
||||
t.Fatalf("enrichPATErrorForHostControl output must be single-line, got embedded newline:\n%s", out)
|
||||
}
|
||||
if strings.HasPrefix(out, " ") || strings.HasPrefix(out, "\t") {
|
||||
t.Fatalf("enrichPATErrorForHostControl output must not be indented, got leading whitespace: %q", out)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
||||
t.Fatalf("single-line output must round-trip via json.Unmarshal: %v\nraw=%s", err, out)
|
||||
}
|
||||
data, _ := parsed["data"].(map[string]any)
|
||||
hostControl, _ := data["hostControl"].(map[string]any)
|
||||
if hostControl == nil {
|
||||
t.Fatalf("expected data.hostControl injection, got: %s", out)
|
||||
}
|
||||
if got, _ := hostControl["callbackOwner"].(string); got != "host" {
|
||||
t.Fatalf("hostControl.callbackOwner = %q, want host", got)
|
||||
}
|
||||
if _, ok := data["callbacks"]; ok {
|
||||
t.Fatalf("expected callbacks to be stripped in host-owned contract, got: %v", data["callbacks"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPATScopeHostJSON_SingleLineOutput mirrors the above regression
|
||||
// for the scope-error branch (PAT_SCOPE_AUTH_REQUIRED emission).
|
||||
func TestBuildPATScopeHostJSON_SingleLineOutput(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agt-support")
|
||||
t.Setenv("DINGTALK_AGENT", "customer-support")
|
||||
|
||||
scopeErr := &PatScopeError{
|
||||
OriginalError: "missing required scope(s): mail:send",
|
||||
Identity: "user",
|
||||
ErrorType: "missing_scope",
|
||||
Message: "missing required scope(s): mail:send",
|
||||
Hint: "run `dws auth login --scope \"mail:send\"` to authorize",
|
||||
MissingScope: "mail:send",
|
||||
}
|
||||
out := buildPATScopeJSON(scopeErr, true)
|
||||
|
||||
if strings.Contains(out, "\n") {
|
||||
t.Fatalf("buildPATScopeJSON(host) output must be single-line, got embedded newline:\n%s", out)
|
||||
}
|
||||
if strings.HasPrefix(out, " ") || strings.HasPrefix(out, "\t") {
|
||||
t.Fatalf("buildPATScopeJSON(host) output must not be indented, got leading whitespace: %q", out)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
||||
t.Fatalf("single-line output must round-trip via json.Unmarshal: %v\nraw=%s", err, out)
|
||||
}
|
||||
if code, _ := parsed["code"].(string); code != "PAT_SCOPE_AUTH_REQUIRED" {
|
||||
t.Errorf("code = %q, want PAT_SCOPE_AUTH_REQUIRED", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithPatAuthRetry_HostControlledReturnsJSON(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agt-support")
|
||||
t.Setenv("DINGTALK_AGENT", "customer-support")
|
||||
|
||||
scopeErr := &PatScopeError{
|
||||
OriginalError: "missing required scope(s): mail:send",
|
||||
Identity: "user",
|
||||
ErrorType: "missing_scope",
|
||||
Message: "missing required scope(s): mail:send",
|
||||
Hint: "run `dws auth login --scope \"mail:send\"` to authorize the missing scope",
|
||||
MissingScope: "mail:send",
|
||||
}
|
||||
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
t.Fatal("runner should not be called in host-controlled scope mode")
|
||||
return executor.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := retryWithPatAuthRetry(context.Background(), mock, executor.Invocation{}, scopeErr, t.TempDir(), &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected PATError")
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output, got %q", got)
|
||||
}
|
||||
patErr, ok := err.(*apperrors.PATError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(patErr.RawJSON), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(scope host payload) error = %v\nraw=%s", err, patErr.RawJSON)
|
||||
}
|
||||
if got, _ := payload["code"].(string); got != "PAT_SCOPE_AUTH_REQUIRED" {
|
||||
t.Fatalf("code = %q, want PAT_SCOPE_AUTH_REQUIRED", got)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, _ := data["missingScope"].(string); got != "mail:send" {
|
||||
t.Fatalf("missingScope = %q, want mail:send", got)
|
||||
}
|
||||
hostControl, _ := data["hostControl"].(map[string]any)
|
||||
if got, _ := hostControl["callbackOwner"].(string); got != "host" {
|
||||
t.Fatalf("hostControl.callbackOwner = %q, want host", got)
|
||||
}
|
||||
if _, ok := data["callbacks"]; ok {
|
||||
t.Fatalf("unexpected callbacks contract in PAT scope host payload: %#v", data["callbacks"])
|
||||
}
|
||||
if strings.Contains(patErr.RawJSON, `"pat","callback"`) {
|
||||
t.Fatalf("scope host payload should not advertise dws pat callback argv: %s", patErr.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_OpensOpaqueURIWithoutRebuild(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
|
||||
defer server.Close()
|
||||
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D50dff7654b7444e88ced7489b07cce8d%26userCode%3DQ8RY-X6E9#/personalAuthorization?flowId=50dff7654b7444e88ced7489b07cce8d&userCode=Q8RY-X6E9"
|
||||
var opened string
|
||||
origOpenBrowser := openBrowserFunc
|
||||
openBrowserFunc = func(rawURL string) error {
|
||||
opened = rawURL
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
|
||||
|
||||
var retryCalled bool
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
retryCalled = true
|
||||
return executor.Result{Response: map[string]any{"ok": true}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{fallback: mock}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-opaque", "test-client-id", rawURI)}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
|
||||
CanonicalProduct: "test",
|
||||
Tool: "test_tool",
|
||||
}, patErr, configDir, &buf)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !retryCalled {
|
||||
t.Fatal("expected retry to run after approved PAT flow")
|
||||
}
|
||||
if opened != rawURI {
|
||||
t.Fatalf("opened url = %q, want verbatim %q", opened, rawURI)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+55
-78
@@ -290,6 +290,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
|
||||
utilityCommands := []*cobra.Command{
|
||||
newAuthCommand(),
|
||||
newAPICommand(flags),
|
||||
newSkillCommand(),
|
||||
newCacheCommand(),
|
||||
newConfigCommand(),
|
||||
@@ -655,18 +656,10 @@ func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executo
|
||||
// Public utility commands (auth, cache, completion, version) are always kept
|
||||
// visible; explicitly hidden commands stay hidden.
|
||||
func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
var allowedProducts map[string]bool
|
||||
if fn := edition.Get().VisibleProducts; fn != nil {
|
||||
products := fn()
|
||||
allowedProducts = make(map[string]bool, len(products))
|
||||
for _, p := range products {
|
||||
allowedProducts[p] = true
|
||||
}
|
||||
} else {
|
||||
allowedProducts = DirectRuntimeProductIDs()
|
||||
}
|
||||
allowedProducts := resolveVisibleProducts()
|
||||
staticCommands := map[string]bool{
|
||||
"auth": true,
|
||||
"api": true,
|
||||
"cache": true,
|
||||
"config": true,
|
||||
"doctor": true,
|
||||
@@ -698,7 +691,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
// not override. This protects core CLI functionality from being hijacked
|
||||
// by a malicious or misconfigured plugin.
|
||||
var reservedCommands = map[string]bool{
|
||||
"auth": true, "login": true, "logout": true,
|
||||
"auth": true, "api": true, "login": true, "logout": true,
|
||||
"plugin": true, "skill": true, "cache": true,
|
||||
"config": true, "doctor": true, "completion": true,
|
||||
"recovery": true, "upgrade": true, "version": true,
|
||||
@@ -1162,11 +1155,31 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
|
||||
sharedStore := cacheStoreFromEnv()
|
||||
coldTimeouts := resolvePluginColdTimeouts()
|
||||
|
||||
// Fan out HTTP and stdio discovery in parallel. Each goroutine resolves
|
||||
// its cache hit locally (no network) or runs a bounded cold-path probe.
|
||||
// Wall-clock cost ≈ max(individual plugin latencies), not the sum.
|
||||
// Phase A: stdio overlay-first registration (synchronous, no I/O).
|
||||
// Plugins whose overlay.json declares ToolOverrides register their full
|
||||
// command tree up-front from manifest metadata alone — no subprocess
|
||||
// handshake required. This fixes the "discovery fails → no commands
|
||||
// ever appear" lock-out and keeps `dws --help` reliable even when the
|
||||
// underlying MCP server is temporarily unavailable.
|
||||
var legacyStdioEntries []stdioEntry
|
||||
for _, e := range stdioEntries {
|
||||
cmds, _, ok := registerStdioServerFromOverlay(e.plugin, e.sc, runner, sharedStore)
|
||||
if !ok {
|
||||
legacyStdioEntries = append(legacyStdioEntries, e)
|
||||
continue
|
||||
}
|
||||
pluginCmds = append(pluginCmds, cmds...)
|
||||
}
|
||||
|
||||
// Phase B: fan out discovery in parallel.
|
||||
// - HTTP plugins: same behaviour as before (discovery-first).
|
||||
// - stdio overlay-first plugins: async cache refresh only; their
|
||||
// commands are already registered. Failures are non-fatal and do
|
||||
// NOT poison the warm-cache with a null-tools snapshot.
|
||||
// - stdio legacy plugins (overlay without toolOverrides): preserve
|
||||
// the old discovery-first path for backwards compatibility.
|
||||
httpResults := make([][]*cobra.Command, len(httpServers))
|
||||
stdioResults := make([][]*cobra.Command, len(stdioEntries))
|
||||
legacyStdioResults := make([][]*cobra.Command, len(legacyStdioEntries))
|
||||
var wg sync.WaitGroup
|
||||
for i, ps := range httpServers {
|
||||
wg.Add(1)
|
||||
@@ -1175,18 +1188,30 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
|
||||
httpResults[idx] = registerHTTPServer(ps.plugin, ps.srv, tc, runner, sharedStore, coldTimeouts)
|
||||
}(i, ps)
|
||||
}
|
||||
for i, e := range stdioEntries {
|
||||
// overlay-first stdio: async refresh (no command building here).
|
||||
for _, e := range stdioEntries {
|
||||
if !hasOverlayToolOverrides(e.plugin, e.sc) {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(e stdioEntry) {
|
||||
defer wg.Done()
|
||||
refreshStdioToolsCache(e.plugin, e.sc, sharedStore, coldTimeouts)
|
||||
}(e)
|
||||
}
|
||||
// legacy stdio: discovery-first (commands depend on tool list).
|
||||
for i, e := range legacyStdioEntries {
|
||||
wg.Add(1)
|
||||
go func(idx int, e stdioEntry) {
|
||||
defer wg.Done()
|
||||
stdioResults[idx] = registerStdioServer(e.plugin, e.sc, runner, sharedStore, coldTimeouts)
|
||||
legacyStdioResults[idx] = registerStdioServer(e.plugin, e.sc, runner, sharedStore, coldTimeouts)
|
||||
}(i, e)
|
||||
}
|
||||
wg.Wait()
|
||||
for _, cmds := range httpResults {
|
||||
pluginCmds = append(pluginCmds, cmds...)
|
||||
}
|
||||
for _, cmds := range stdioResults {
|
||||
for _, cmds := range legacyStdioResults {
|
||||
pluginCmds = append(pluginCmds, cmds...)
|
||||
}
|
||||
|
||||
@@ -1472,6 +1497,11 @@ func discoverStdioTools(p *plugin.Plugin, sc plugin.StdioServerClient, timeouts
|
||||
// buildStdioCommands constructs Cobra commands from a tool list and
|
||||
// registers the runtime dispatch state (StdioClient + dynamic server).
|
||||
// Returns nil for an empty tool list.
|
||||
//
|
||||
// This is the legacy discovery-first path, used only for stdio plugins whose
|
||||
// overlay.json does NOT carry toolOverrides. Plugins that ship toolOverrides
|
||||
// register commands up-front via registerStdioServerFromOverlay, bypassing
|
||||
// this function entirely (see plugin_stdio_overlay.go).
|
||||
func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []transport.ToolDescriptor, runner executor.Runner) []*cobra.Command {
|
||||
if len(tools) == 0 {
|
||||
slog.Debug("plugin: stdio server has no tools",
|
||||
@@ -1479,44 +1509,14 @@ func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []t
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build CLIOverlay: use manifest CLI metadata if present, else auto-generate.
|
||||
serverID := sc.Key
|
||||
overlay := market.CLIOverlay{
|
||||
ID: serverID,
|
||||
Command: serverID,
|
||||
}
|
||||
if srv, ok := p.Manifest.MCPServers[sc.Key]; ok && len(srv.CLI) > 0 {
|
||||
cliData := srv.CLI
|
||||
// If cli is a JSON string, treat it as a relative file path to an overlay file.
|
||||
if len(cliData) > 0 && cliData[0] == '"' {
|
||||
var cliPath string
|
||||
if err := json.Unmarshal(cliData, &cliPath); err == nil && cliPath != "" {
|
||||
absPath := filepath.Join(p.Root, cliPath)
|
||||
if fileData, readErr := os.ReadFile(absPath); readErr == nil {
|
||||
cliData = fileData
|
||||
} else {
|
||||
slog.Warn("plugin: failed to read CLI overlay file",
|
||||
"plugin", p.Manifest.Name, "path", absPath, "error", readErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(cliData, &overlay); err != nil {
|
||||
slog.Warn("plugin: failed to parse CLI overlay for stdio server",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
}
|
||||
if overlay.ID == "" {
|
||||
overlay.ID = serverID
|
||||
}
|
||||
if overlay.Command == "" {
|
||||
overlay.Command = serverID
|
||||
}
|
||||
}
|
||||
overlay := resolveStdioOverlay(p, sc)
|
||||
|
||||
// Auto-generate ToolOverrides from discovered tools when not provided.
|
||||
// Auto-generate ToolOverrides from discovered tools when not provided
|
||||
// by the manifest/overlay (legacy discovery-first path).
|
||||
if len(overlay.ToolOverrides) == 0 {
|
||||
overlay.ToolOverrides = make(map[string]market.CLIToolOverride)
|
||||
if len(overlay.Prefixes) == 0 {
|
||||
overlay.Prefixes = []string{serverID}
|
||||
overlay.Prefixes = []string{overlay.ID}
|
||||
}
|
||||
for _, tool := range tools {
|
||||
overlay.ToolOverrides[tool.Name] = market.CLIToolOverride{
|
||||
@@ -1525,43 +1525,20 @@ func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []t
|
||||
}
|
||||
}
|
||||
|
||||
// Construct virtual endpoint and server descriptor.
|
||||
endpoint := StdioEndpoint(p.Manifest.Name, sc.Key)
|
||||
|
||||
descriptor := market.ServerDescriptor{
|
||||
Key: sc.Key,
|
||||
DisplayName: p.Manifest.Name + "/" + sc.Key,
|
||||
Description: p.Manifest.Description,
|
||||
Endpoint: endpoint,
|
||||
Endpoint: StdioEndpoint(p.Manifest.Name, sc.Key),
|
||||
Source: "plugin",
|
||||
CLI: overlay,
|
||||
HasCLIMeta: true,
|
||||
}
|
||||
|
||||
AppendDynamicServer(descriptor)
|
||||
// Register with pluginName/serverKey format for cleanup by plugin name
|
||||
RegisterStdioClient(p.Manifest.Name+"/"+serverID, sc.Client)
|
||||
|
||||
// Convert tool descriptors to DetailTool entries for flag generation.
|
||||
detailsByID := make(map[string][]market.DetailTool)
|
||||
var detailTools []market.DetailTool
|
||||
for _, tool := range tools {
|
||||
schemaJSON := ""
|
||||
if tool.InputSchema != nil {
|
||||
if data, marshalErr := json.Marshal(tool.InputSchema); marshalErr == nil {
|
||||
schemaJSON = string(data)
|
||||
}
|
||||
}
|
||||
detailTools = append(detailTools, market.DetailTool{
|
||||
ToolName: tool.Name,
|
||||
ToolTitle: tool.Title,
|
||||
ToolDesc: tool.Description,
|
||||
IsSensitive: tool.Sensitive,
|
||||
ToolRequest: schemaJSON,
|
||||
})
|
||||
}
|
||||
detailsByID[serverID] = detailTools
|
||||
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
|
||||
|
||||
detailsByID := toolsToDetails(tools, overlay.ID)
|
||||
cmds := compat.BuildDynamicCommands(
|
||||
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
|
||||
|
||||
|
||||
+20
-17
@@ -84,21 +84,30 @@ func renderRootHelp(root *cobra.Command) {
|
||||
_, _ = fmt.Fprintln(w, `Use "dws <service> --help" for more information about a discovered MCP service or "dws <command> --help" for utility commands.`)
|
||||
}
|
||||
|
||||
// resolveVisibleProducts returns the set of top-level product IDs that should
|
||||
// be treated as visible. It unions the edition's VisibleProducts hook (when
|
||||
// set) with DirectRuntimeProductIDs(), so dynamically-registered products —
|
||||
// including plugins loaded via AppendDynamicServer — are never silently hidden
|
||||
// by a static VisibleProducts list.
|
||||
func resolveVisibleProducts() map[string]bool {
|
||||
allowed := map[string]bool{}
|
||||
if fn := edition.Get().VisibleProducts; fn != nil {
|
||||
for _, p := range fn() {
|
||||
allowed[p] = true
|
||||
}
|
||||
}
|
||||
for id := range DirectRuntimeProductIDs() {
|
||||
allowed[id] = true
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command {
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var allowed map[string]bool
|
||||
if fn := edition.Get().VisibleProducts; fn != nil {
|
||||
products := fn()
|
||||
allowed = make(map[string]bool, len(products))
|
||||
for _, p := range products {
|
||||
allowed[p] = true
|
||||
}
|
||||
} else {
|
||||
allowed = DirectRuntimeProductIDs()
|
||||
}
|
||||
allowed := resolveVisibleProducts()
|
||||
if len(allowed) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -121,13 +130,7 @@ func visibleUtilityRootCommands(root *cobra.Command) []*cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
productCommands := DirectRuntimeProductIDs()
|
||||
if fn := edition.Get().VisibleProducts; fn != nil {
|
||||
productCommands = make(map[string]bool, len(fn()))
|
||||
for _, product := range fn() {
|
||||
productCommands[product] = true
|
||||
}
|
||||
}
|
||||
productCommands := resolveVisibleProducts()
|
||||
|
||||
commands := make([]*cobra.Command, 0)
|
||||
for _, cmd := range root.Commands() {
|
||||
|
||||
+51
-6
@@ -59,7 +59,7 @@ func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DINGTALK_AGENT",
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "MCP 请求 x-dingtalk-agent 头",
|
||||
Description: "业务 Agent 名称;仅用于 x-dingtalk-agent 请求头,与 claw-type/host-owned PAT 判定无关",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DINGTALK_TRACE_ID",
|
||||
@@ -93,6 +93,27 @@ const (
|
||||
envDWSChannel = "DWS_CHANNEL"
|
||||
)
|
||||
|
||||
// hostOwnedPATDecisionOnce ensures the host-owned PAT decision is logged at
|
||||
// most once per CLI process. The log line is emitted at Debug level so
|
||||
// `--debug` (or `--verbose`) surfaces it on stderr; the file logger at
|
||||
// ~/.dws/logs/dws.log captures it unconditionally at DEBUG. It records
|
||||
// ONLY the derived booleans — never the env value, token, client-id or
|
||||
// flow-id — so logs remain safe to attach to issues.
|
||||
var hostOwnedPATDecisionOnce sync.Once
|
||||
|
||||
// logHostOwnedPATDecisionOnce emits the single-shot debug trace. It is
|
||||
// called lazily from the runtime Run path (which executes AFTER
|
||||
// PersistentPreRunE has applied --debug / --verbose via configureLogLevel)
|
||||
// so the line actually surfaces when the user asks for it.
|
||||
func logHostOwnedPATDecisionOnce() {
|
||||
hostOwnedPATDecisionOnce.Do(func() {
|
||||
slog.Debug("runtime.host_owned_pat",
|
||||
"hostOwned", authpkg.HostOwnsPATFlow(),
|
||||
"agentCodeEnvPresent", os.Getenv(authpkg.AgentCodeEnv) != "",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) executor.Runner {
|
||||
// Ensure DWS_CLIENT_ID env is populated from persisted config before
|
||||
// resolveIdentityHeaders reads it. This covers fresh-process cold starts
|
||||
@@ -132,6 +153,12 @@ type runtimeRunner struct {
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
// Emit the one-shot host-owned PAT decision log. Placed here (not in
|
||||
// the constructor) so it fires AFTER PersistentPreRunE has configured
|
||||
// slog level per --debug / --verbose. The Once guard makes repeat
|
||||
// invocations within the same process free.
|
||||
logHostOwnedPATDecisionOnce()
|
||||
|
||||
if r.loader == nil || r.transport == nil {
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
@@ -171,6 +198,17 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
return r.handleCatalogMiss(ctx, invocation, "product missing from discovery catalog and no supplement/env override")
|
||||
}
|
||||
if _, ok := product.FindTool(invocation.Tool); !ok {
|
||||
// Catalog knows the product but not the tool — this happens when the
|
||||
// catalog entry came from SupplementServers (endpoint-only, no tool
|
||||
// list). Trust directRuntimeEndpoint to re-resolve a working endpoint
|
||||
// for the tool. If that also misses, fall through to handleCatalogMiss
|
||||
// so stderr still carries the explicit not-resolved signal.
|
||||
if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok {
|
||||
if r.globalFlags != nil && r.globalFlags.DryRun {
|
||||
invocation.DryRun = true
|
||||
}
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
}
|
||||
return r.handleCatalogMiss(ctx, invocation, fmt.Sprintf("tool %q not declared by product %q in discovery catalog", invocation.Tool, invocation.CanonicalProduct))
|
||||
}
|
||||
if r.globalFlags != nil && r.globalFlags.DryRun {
|
||||
@@ -610,11 +648,17 @@ func resolveIdentityHeaders() map[string]string {
|
||||
}
|
||||
|
||||
// Inject environment variable based headers for MCP gateway tracking.
|
||||
// DINGTALK_AGENT, if set by the caller, is forwarded verbatim as the
|
||||
// x-dingtalk-agent header. It does NOT influence claw-type (which the
|
||||
// open-source edition pins to edition.DefaultOSSClawType via the
|
||||
// MergeHeaders hook below) and it does NOT influence the host-owned
|
||||
// PAT decision (driven solely by DINGTALK_DWS_AGENTCODE).
|
||||
envHeaders := map[string]string{
|
||||
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
|
||||
"x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID),
|
||||
"x-dingtalk-session-id": os.Getenv(envDingtalkSessionID),
|
||||
"x-dingtalk-message-id": os.Getenv(envDingtalkMessageID),
|
||||
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
|
||||
"x-dingtalk-dws-agent-code": strings.TrimSpace(os.Getenv(authpkg.AgentCodeEnv)),
|
||||
"x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID),
|
||||
"x-dingtalk-session-id": os.Getenv(envDingtalkSessionID),
|
||||
"x-dingtalk-message-id": os.Getenv(envDingtalkMessageID),
|
||||
}
|
||||
for k, v := range envHeaders {
|
||||
if v != "" {
|
||||
@@ -622,7 +666,8 @@ func resolveIdentityHeaders() map[string]string {
|
||||
}
|
||||
}
|
||||
|
||||
// Inject third-party channel headers
|
||||
// Inject third-party channel headers. DWS_CHANNEL is forwarded as the
|
||||
// upstream channelCode.
|
||||
if v := os.Getenv(envDWSChannel); v != "" {
|
||||
headers["x-dws-channel"] = v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
// supplementOnlyCatalogLoader mimics the post-fix EnvironmentLoader: the
|
||||
// catalog has the product entry (materialised from SupplementServers) but
|
||||
// no tool list — the overlay owns the tool tree locally.
|
||||
type supplementOnlyCatalogLoader struct{}
|
||||
|
||||
func (supplementOnlyCatalogLoader) Load(_ context.Context) (ir.Catalog, error) {
|
||||
return ir.Catalog{
|
||||
Products: []ir.CanonicalProduct{
|
||||
{
|
||||
ID: "conference",
|
||||
ServerKey: "conference",
|
||||
Endpoint: "stdio://conference-catalog",
|
||||
Tools: nil,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resetDynamicServers(t *testing.T) {
|
||||
t.Helper()
|
||||
orig := snapshotDynamicServers()
|
||||
t.Cleanup(func() { restoreDynamicServers(orig) })
|
||||
}
|
||||
|
||||
type dynamicServerSnapshot struct {
|
||||
endpoints map[string]string
|
||||
products map[string]bool
|
||||
aliases map[string]string
|
||||
toolEndpoints map[string]string
|
||||
}
|
||||
|
||||
func snapshotDynamicServers() dynamicServerSnapshot {
|
||||
dynamicMu.RLock()
|
||||
defer dynamicMu.RUnlock()
|
||||
return dynamicServerSnapshot{
|
||||
endpoints: cloneStringMap(dynamicEndpoints),
|
||||
products: cloneBoolMap(dynamicProducts),
|
||||
aliases: cloneStringMap(dynamicAliases),
|
||||
toolEndpoints: cloneStringMap(dynamicToolEndpoints),
|
||||
}
|
||||
}
|
||||
|
||||
func restoreDynamicServers(s dynamicServerSnapshot) {
|
||||
dynamicMu.Lock()
|
||||
defer dynamicMu.Unlock()
|
||||
dynamicEndpoints = s.endpoints
|
||||
dynamicProducts = s.products
|
||||
dynamicAliases = s.aliases
|
||||
dynamicToolEndpoints = s.toolEndpoints
|
||||
}
|
||||
|
||||
func cloneStringMap(in map[string]string) map[string]string {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneBoolMap(in map[string]bool) map[string]bool {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]bool, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestRuntimeRunner_ToolMiss_FallsBackToDirectRuntime pins the runner's
|
||||
// bridge between the catalog path (where a product entry can come from
|
||||
// SupplementServers with no tool list) and the direct-runtime path (which
|
||||
// carries the authoritative per-tool endpoint map). When the catalog knows
|
||||
// the product but not the tool, the runner should not fail-fast with
|
||||
// endpoint_not_resolved — it should consult dynamicEndpoints one more time
|
||||
// and proceed if an endpoint is registered.
|
||||
//
|
||||
// This is the narrow recovery path that keeps hardcoded overlay commands
|
||||
// working under a gray-released envelope: the supplement-materialised
|
||||
// catalog entry has endpoint+no tools, and SetDynamicServers holds the
|
||||
// operational endpoint indexed by product / command.
|
||||
func TestRuntimeRunner_ToolMiss_FallsBackToDirectRuntime(t *testing.T) {
|
||||
resetDynamicServers(t)
|
||||
SetDynamicServers([]market.ServerDescriptor{
|
||||
{
|
||||
Key: "conference",
|
||||
DisplayName: "会议",
|
||||
Endpoint: "stdio://conference-fake",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "conference",
|
||||
Command: "conference",
|
||||
},
|
||||
Source: "edition_supplement",
|
||||
},
|
||||
})
|
||||
|
||||
runner := &runtimeRunner{
|
||||
loader: supplementOnlyCatalogLoader{},
|
||||
transport: transport.NewClient(nil),
|
||||
fallback: executor.EchoRunner{},
|
||||
}
|
||||
|
||||
// Kind = api_invocation forces the code to skip the Run() opening
|
||||
// direct-runtime attempt and go through the catalog path instead, so
|
||||
// the tool-miss recovery branch we're testing actually runs.
|
||||
inv := executor.Invocation{
|
||||
Kind: "api_invocation",
|
||||
CanonicalProduct: "conference",
|
||||
Tool: "create_meeting_reservation",
|
||||
CanonicalPath: "conference.create_meeting_reservation",
|
||||
DryRun: true,
|
||||
Params: map[string]any{},
|
||||
}
|
||||
|
||||
result, err := runner.Run(context.Background(), inv)
|
||||
if err != nil {
|
||||
t.Fatalf("runner.Run returned error, want tool-miss fallback success: %v", err)
|
||||
}
|
||||
if result.Response == nil {
|
||||
t.Fatalf("expected non-nil Response on dry-run")
|
||||
}
|
||||
if got, _ := result.Response["dry_run"].(bool); !got {
|
||||
t.Fatalf("expected dry_run=true in Response, got %v", result.Response)
|
||||
}
|
||||
if got, _ := result.Response["transport"].(string); got != "stdio" {
|
||||
t.Fatalf("expected transport=stdio in Response (proof we hit stdio://conference-fake), got %v", result.Response)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeRunner_ToolMiss_NoDynamicEntry_StillFailsClosed is the inverse
|
||||
// guard: when both the catalog tool list and dynamicEndpoints have no
|
||||
// record for the requested tool, the runner must still surface
|
||||
// endpoint_not_resolved instead of silently producing empty output.
|
||||
func TestRuntimeRunner_ToolMiss_NoDynamicEntry_StillFailsClosed(t *testing.T) {
|
||||
resetDynamicServers(t)
|
||||
SetDynamicServers([]market.ServerDescriptor{}) // intentionally empty
|
||||
|
||||
runner := &runtimeRunner{
|
||||
loader: supplementOnlyCatalogLoader{},
|
||||
transport: transport.NewClient(nil),
|
||||
fallback: executor.EchoRunner{},
|
||||
}
|
||||
|
||||
inv := executor.Invocation{
|
||||
Kind: "api_invocation",
|
||||
CanonicalProduct: "conference",
|
||||
Tool: "nonexistent_tool",
|
||||
CanonicalPath: "conference.nonexistent_tool",
|
||||
Params: map[string]any{},
|
||||
}
|
||||
|
||||
_, err := runner.Run(context.Background(), inv)
|
||||
if err == nil {
|
||||
t.Fatalf("expected endpoint_not_resolved error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "endpoint not resolved") {
|
||||
t.Fatalf("expected endpoint_not_resolved error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nonexistent_tool") {
|
||||
t.Fatalf("error should name the missing tool; got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -313,6 +314,16 @@ func TestRuntimeRunnerInjectsAuthTokenFromFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv(authpkg.AgentCodeEnv, " cursor ")
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers["x-dingtalk-dws-agent-code"]; got != "cursor" {
|
||||
t.Fatalf("x-dingtalk-dws-agent-code = %q, want cursor", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeRunnerRejectsUnauthenticatedRequest verifies that requests without
|
||||
// a valid token are rejected with a clear error before making any network call.
|
||||
func TestRuntimeRunnerRejectsUnauthenticatedRequest(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// overrideVisibleProducts temporarily installs an edition hook exposing the
|
||||
// given static product list and restores the previous hooks on cleanup.
|
||||
func overrideVisibleProducts(t *testing.T, products []string) {
|
||||
t.Helper()
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
VisibleProducts: func() []string { return products },
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
}
|
||||
|
||||
// registerPluginProduct simulates a plugin's `AppendDynamicServer` call so
|
||||
// the product ID ends up in DirectRuntimeProductIDs() without triggering
|
||||
// network discovery.
|
||||
func registerPluginProduct(t *testing.T, id, endpoint string) {
|
||||
t.Helper()
|
||||
AppendDynamicServer(market.ServerDescriptor{
|
||||
Endpoint: endpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: id,
|
||||
Command: id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestHideNonDirectRuntimeCommands_PluginVisibleDespiteStaticVisibleProducts
|
||||
// is a regression for the dws-wukong plugin-visibility bug: when an edition
|
||||
// installs a static VisibleProducts hook (Wukong returns 40 hardcoded product
|
||||
// IDs) and a plugin registers a new product via AppendDynamicServer
|
||||
// (e.g. `conference-local`), the plugin command must stay visible because the
|
||||
// dynamic registry takes precedence over the hook's static whitelist.
|
||||
func TestHideNonDirectRuntimeCommands_PluginVisibleDespiteStaticVisibleProducts(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
overrideVisibleProducts(t, []string{"calendar"})
|
||||
registerPluginProduct(t, "conference-local", "stdio://plugin/conference-local")
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
calendarCmd := &cobra.Command{Use: "calendar"}
|
||||
pluginCmd := &cobra.Command{Use: "conference-local"}
|
||||
bogusCmd := &cobra.Command{Use: "bogus-not-a-product"}
|
||||
root.AddCommand(calendarCmd, pluginCmd, bogusCmd)
|
||||
|
||||
hideNonDirectRuntimeCommands(root)
|
||||
|
||||
if calendarCmd.Hidden {
|
||||
t.Errorf("calendar (static VisibleProducts) must stay visible, got Hidden=true")
|
||||
}
|
||||
if pluginCmd.Hidden {
|
||||
t.Errorf("conference-local (plugin-registered) must stay visible, got Hidden=true")
|
||||
}
|
||||
if !bogusCmd.Hidden {
|
||||
t.Errorf("bogus-not-a-product must be hidden, got Hidden=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVisibleMCPRootCommands_IncludesPluginProducts asserts that the help
|
||||
// renderer surfaces plugin products in the "Discovered MCP Services" section
|
||||
// and does not misclassify them as utility commands.
|
||||
func TestVisibleMCPRootCommands_IncludesPluginProducts(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
overrideVisibleProducts(t, []string{"calendar"})
|
||||
registerPluginProduct(t, "conference-local", "stdio://plugin/conference-local")
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
calendarCmd := &cobra.Command{Use: "calendar"}
|
||||
pluginCmd := &cobra.Command{Use: "conference-local"}
|
||||
authCmd := &cobra.Command{Use: "auth"}
|
||||
root.AddCommand(calendarCmd, pluginCmd, authCmd)
|
||||
|
||||
services := visibleMCPRootCommands(root)
|
||||
if !containsCommand(services, "conference-local") {
|
||||
t.Errorf("visibleMCPRootCommands missing plugin command: %v", commandNames(services))
|
||||
}
|
||||
if !containsCommand(services, "calendar") {
|
||||
t.Errorf("visibleMCPRootCommands missing static product: %v", commandNames(services))
|
||||
}
|
||||
|
||||
utilities := visibleUtilityRootCommands(root)
|
||||
if containsCommand(utilities, "conference-local") {
|
||||
t.Errorf("visibleUtilityRootCommands must not include plugin command, got %v", commandNames(utilities))
|
||||
}
|
||||
if !containsCommand(utilities, "auth") {
|
||||
t.Errorf("visibleUtilityRootCommands must include genuine utility command, got %v", commandNames(utilities))
|
||||
}
|
||||
}
|
||||
|
||||
func containsCommand(cmds []*cobra.Command, name string) bool {
|
||||
for _, c := range cmds {
|
||||
if c.Name() == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func commandNames(cmds []*cobra.Command) []string {
|
||||
names := make([]string, 0, len(cmds))
|
||||
for _, c := range cmds {
|
||||
names = append(names, c.Name())
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -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)) != ""
|
||||
}
|
||||
@@ -112,6 +112,10 @@ type DevicePollResponse struct {
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data DevicePollData `json:"data"`
|
||||
// Result is an alternate envelope some service versions return instead of
|
||||
// (or alongside) Data. Always read poll fields via EffectiveData() rather
|
||||
// than touching Data/Result directly.
|
||||
Result DevicePollData `json:"result"`
|
||||
}
|
||||
|
||||
type DevicePollData struct {
|
||||
@@ -120,6 +124,20 @@ type DevicePollData struct {
|
||||
FlowID string `json:"flowId,omitempty"`
|
||||
}
|
||||
|
||||
// EffectiveData normalizes terminal poll responses that may carry payload
|
||||
// fields under either `data` or `result`.
|
||||
//
|
||||
// Semantics are envelope-level rather than field-level: when Data includes a
|
||||
// non-empty status, treat Data as the authoritative payload and return it
|
||||
// unchanged; otherwise fall back to Result. This avoids mixing fields from two
|
||||
// disagreeing envelopes into a Frankenstein result.
|
||||
func (r DevicePollResponse) EffectiveData() DevicePollData {
|
||||
if r.Data.Status != "" || r.Result.Status == "" {
|
||||
return r.Data
|
||||
}
|
||||
return r.Result
|
||||
}
|
||||
|
||||
type serviceResult struct {
|
||||
Success bool `json:"success"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
@@ -287,16 +305,19 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
|
||||
}
|
||||
|
||||
// Persist app credentials (with secret) if using custom client credentials.
|
||||
// MUST run BEFORE os.Setenv below to avoid env-matching short circuit.
|
||||
oauthProvider.persistAppConfigIfNeeded()
|
||||
|
||||
// Always persist clientId to app.json so future process startups
|
||||
// can load it via ResolveAppCredentials and populate DWS_CLIENT_ID env.
|
||||
if p.clientID != "" {
|
||||
_ = os.Setenv("DWS_CLIENT_ID", p.clientID)
|
||||
_ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
|
||||
if !HasAppConfig(p.configDir) {
|
||||
_ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
|
||||
}
|
||||
}
|
||||
|
||||
// Persist app credentials if using custom client credentials
|
||||
oauthProvider.persistAppConfigIfNeeded()
|
||||
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
@@ -428,10 +449,11 @@ func (p *DeviceFlowProvider) waitForAuthorizationByFlowID(ctx context.Context, a
|
||||
continue
|
||||
}
|
||||
|
||||
switch pollResp.Data.Status {
|
||||
pollData := pollResp.EffectiveData()
|
||||
switch pollData.Status {
|
||||
case StatusApproved:
|
||||
dfPrintPollResult(p.output(), "authorized", i18n.T("授权成功!"))
|
||||
return &DeviceTokenResponse{AuthCode: pollResp.Data.AuthCode}, nil
|
||||
return &DeviceTokenResponse{AuthCode: pollData.AuthCode}, nil
|
||||
case StatusPending:
|
||||
dfPrintPollResult(p.output(), "pending", i18n.T("等待用户授权..."))
|
||||
case StatusRejected:
|
||||
@@ -441,7 +463,7 @@ func (p *DeviceFlowProvider) waitForAuthorizationByFlowID(ctx context.Context, a
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("设备授权码已过期"))
|
||||
default:
|
||||
dfPrintPollResult(p.output(), "unknown", fmt.Sprintf(i18n.T("未知状态: %s"), pollResp.Data.Status))
|
||||
dfPrintPollResult(p.output(), "unknown", fmt.Sprintf(i18n.T("未知状态: %s"), pollData.Status))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,104 @@ func TestWaitForAuthorizationSucceedsAfterPending(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAuthorizationAcceptsResultEnvelope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("method = %s, want GET", r.Method)
|
||||
}
|
||||
if calls.Add(1) <= 2 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"result": map[string]string{"status": "PENDING"},
|
||||
})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"result": map[string]string{
|
||||
"status": "APPROVED",
|
||||
"authCode": "final-auth-code",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
|
||||
provider.Output = io.Discard
|
||||
provider.SetTerminalBaseURL(server.URL)
|
||||
|
||||
resp, err := provider.waitForAuthorization(context.Background(), &DeviceAuthResponse{
|
||||
FlowID: "test-flow-id-result",
|
||||
ExpiresIn: 10,
|
||||
Interval: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForAuthorization() error = %v", err)
|
||||
}
|
||||
if resp.AuthCode != "final-auth-code" {
|
||||
t.Fatalf("auth code = %q, want final-auth-code", resp.AuthCode)
|
||||
}
|
||||
if calls.Load() != 3 {
|
||||
t.Fatalf("poll calls = %d, want 3", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevicePollResponseEffectiveData_FallsBackToResultEnvelope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resp := DevicePollResponse{
|
||||
Success: true,
|
||||
Result: DevicePollData{
|
||||
Status: "APPROVED",
|
||||
AuthCode: "auth-from-result",
|
||||
FlowID: "flow-from-result",
|
||||
},
|
||||
}
|
||||
|
||||
effective := resp.EffectiveData()
|
||||
if effective.Status != "APPROVED" {
|
||||
t.Fatalf("effective.Status = %q, want APPROVED", effective.Status)
|
||||
}
|
||||
if effective.AuthCode != "auth-from-result" {
|
||||
t.Fatalf("effective.AuthCode = %q, want auth-from-result", effective.AuthCode)
|
||||
}
|
||||
if effective.FlowID != "flow-from-result" {
|
||||
t.Fatalf("effective.FlowID = %q, want flow-from-result", effective.FlowID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevicePollResponseEffectiveData_DataEnvelopeWinsAsWholePayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resp := DevicePollResponse{
|
||||
Success: true,
|
||||
Data: DevicePollData{
|
||||
Status: "PENDING",
|
||||
},
|
||||
Result: DevicePollData{
|
||||
Status: "APPROVED",
|
||||
AuthCode: "auth-from-result",
|
||||
FlowID: "flow-from-result",
|
||||
},
|
||||
}
|
||||
|
||||
effective := resp.EffectiveData()
|
||||
if effective.Status != "PENDING" {
|
||||
t.Fatalf("effective.Status = %q, want PENDING", effective.Status)
|
||||
}
|
||||
if effective.AuthCode != "" {
|
||||
t.Fatalf("effective.AuthCode = %q, want empty because Data envelope wins as a whole", effective.AuthCode)
|
||||
}
|
||||
if effective.FlowID != "" {
|
||||
t.Fatalf("effective.FlowID = %q, want empty because Data envelope wins as a whole", effective.FlowID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAuthorizationFallsBackToDeviceCodeWhenFlowIDMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -106,6 +106,12 @@ const (
|
||||
MCPOAuthTokenPath = "/oauth2/getToken"
|
||||
MCPRefreshTokenPath = "/oauth2/refreshToken"
|
||||
MCPRevokeTokenPath = "/oauth2/revokeToken"
|
||||
|
||||
// App-level access token endpoints (for dws api raw calls).
|
||||
|
||||
// AppAccessTokenURL is the unified app-level access token endpoint.
|
||||
// POST with {"appKey":"X","appSecret":"X"} → {"accessToken":"...","expireIn":7200}
|
||||
AppAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
|
||||
)
|
||||
|
||||
// GetTerminalBaseURL returns the terminal base URL with priority:
|
||||
@@ -290,11 +296,6 @@ func getRuntimeCredentials() (clientID, clientSecret string) {
|
||||
return runtimeClientID, runtimeClientSecret
|
||||
}
|
||||
|
||||
// getEnvClientID returns the environment variable client ID if set.
|
||||
func getEnvClientID() string {
|
||||
return os.Getenv("DWS_CLIENT_ID")
|
||||
}
|
||||
|
||||
// getDefaultConfigDir returns the default configuration directory.
|
||||
// Priority: DWS_CONFIG_DIR env var > ~/.dws
|
||||
func getDefaultConfigDir() string {
|
||||
|
||||
@@ -85,6 +85,10 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("access_token still valid, skipping login")
|
||||
}
|
||||
// Even on early return, persist custom app credentials if provided
|
||||
// via --client-id/--client-secret flags. Without this, the flags
|
||||
// are only in runtime globals and lost when the process exits.
|
||||
p.persistAppConfigIfNeeded()
|
||||
return data, nil
|
||||
}
|
||||
// Case 2: refresh using refresh_token (with lock to prevent concurrent refresh).
|
||||
@@ -94,6 +98,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
refreshed, rErr := p.lockedRefresh(ctx)
|
||||
if rErr == nil {
|
||||
p.persistAppConfigIfNeeded()
|
||||
return refreshed, nil
|
||||
}
|
||||
if p.logger != nil {
|
||||
@@ -496,16 +501,19 @@ continueLogin:
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
|
||||
}
|
||||
|
||||
// Persist app credentials (with secret) if using custom client credentials.
|
||||
// MUST run BEFORE os.Setenv below to avoid env-matching short circuit.
|
||||
p.persistAppConfigIfNeeded()
|
||||
|
||||
// Always persist clientId to app.json so future process startups
|
||||
// can load it via ResolveAppCredentials and populate DWS_CLIENT_ID env.
|
||||
if p.clientID != "" {
|
||||
_ = os.Setenv("DWS_CLIENT_ID", p.clientID)
|
||||
_ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
|
||||
if !HasAppConfig(p.configDir) {
|
||||
_ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
|
||||
}
|
||||
}
|
||||
|
||||
// Persist app credentials if using custom client credentials
|
||||
p.persistAppConfigIfNeeded()
|
||||
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
@@ -627,9 +635,8 @@ func (p *OAuthProvider) persistAppConfigIfNeeded() {
|
||||
return
|
||||
}
|
||||
|
||||
// Only persist if they differ from environment/default values
|
||||
envID := getEnvClientID()
|
||||
if clientID == envID || clientID == DefaultClientID {
|
||||
// Skip if using default placeholder credentials
|
||||
if clientID == DefaultClientID {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+123
-2
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/editionmerge"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
@@ -196,14 +197,23 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
|
||||
return FixtureLoader{Path: fixturePath}.Load(ctx)
|
||||
}
|
||||
|
||||
// Priority: explicit test override > edition-specific discovery URL >
|
||||
// open-source default. For Wukong this pulls the runtime catalog fetch
|
||||
// onto the same Portal endpoint that loadDynamicCommands already uses,
|
||||
// eliminating the historical split where the command tree came from
|
||||
// Wukong Portal while runtime endpoint resolution silently read the
|
||||
// open-source Market cache (see fix-wukong-endpoint-partition plan).
|
||||
baseURL := DefaultMarketBaseURL
|
||||
if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" {
|
||||
baseURL = editionURL
|
||||
}
|
||||
if l.CatalogBaseURLOverride != "" {
|
||||
baseURL = l.CatalogBaseURLOverride
|
||||
}
|
||||
|
||||
cacheDir, _ := l.lookup(CacheDirEnv)
|
||||
store := cache.NewStore(cacheDir)
|
||||
partition := config.DefaultPartition
|
||||
partition := config.EditionPartition(edition.Get().Name)
|
||||
|
||||
// Cache-first: if a cached catalog is available, use it immediately.
|
||||
// Startup command construction should not block on synchronous discovery
|
||||
@@ -223,6 +233,15 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
|
||||
}
|
||||
|
||||
if !hasAuth {
|
||||
// Cache / discovery both unreachable without credentials — fall back
|
||||
// to the edition's SupplementServers / FallbackServers hook so that
|
||||
// hardcoded overlay commands can still resolve an endpoint via the
|
||||
// returned catalog. Without this an unauthenticated cold start
|
||||
// produces DegradedUnauthenticated and every hardcoded command
|
||||
// fails even when the edition carries its own static endpoint map.
|
||||
if fb := fallbackRuntimeServers(); len(fb) > 0 {
|
||||
return ir.BuildCatalog(fb), nil
|
||||
}
|
||||
return ir.Catalog{}, newCatalogDegraded(DegradedUnauthenticated, 0)
|
||||
}
|
||||
|
||||
@@ -247,6 +266,9 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
|
||||
if cached.Available && len(cached.Catalog.Products) > 0 {
|
||||
return cached.Catalog, nil
|
||||
}
|
||||
if fb := fallbackRuntimeServers(); len(fb) > 0 {
|
||||
return ir.BuildCatalog(fb), nil
|
||||
}
|
||||
return ir.Catalog{}, newCatalogDegraded(DegradedMarketUnreachable, 0)
|
||||
}
|
||||
|
||||
@@ -287,6 +309,9 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
|
||||
if cached.Available && len(cached.Catalog.Products) > 0 {
|
||||
return cached.Catalog, nil
|
||||
}
|
||||
if fb := fallbackRuntimeServers(); len(fb) > 0 {
|
||||
return ir.BuildCatalog(fb), nil
|
||||
}
|
||||
return ir.Catalog{}, newCatalogDegraded(DegradedRuntimeAllFailed, len(servers))
|
||||
}
|
||||
|
||||
@@ -305,6 +330,7 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
|
||||
runtimeServers = append(runtimeServers, runtimeServer)
|
||||
}
|
||||
}
|
||||
runtimeServers = appendSupplementRuntimeServers(runtimeServers)
|
||||
return ir.BuildCatalog(runtimeServers), nil
|
||||
}
|
||||
|
||||
@@ -313,15 +339,28 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
|
||||
// window, the returned state asks the caller to try live discovery before
|
||||
// trusting the cache as current truth.
|
||||
func (l EnvironmentLoader) loadFromCache(store *cache.Store) cachedCatalogState {
|
||||
partition := config.DefaultPartition
|
||||
partition := config.EditionPartition(edition.Get().Name)
|
||||
regSnap, freshness, err := store.LoadRegistry(partition)
|
||||
if err != nil || len(regSnap.Servers) == 0 {
|
||||
// No cached registry. Still honour the edition's SupplementServers
|
||||
// hook so that hardcoded overlay commands whose products are not
|
||||
// part of the Portal envelope (Wukong gray-release in particular)
|
||||
// can resolve an endpoint via the catalog path as well.
|
||||
if supplement := supplementRuntimeServers(nil); len(supplement) > 0 {
|
||||
return cachedCatalogState{
|
||||
Catalog: ir.BuildCatalog(supplement),
|
||||
Registry: regSnap,
|
||||
Available: true,
|
||||
NeedsRevalidate: true,
|
||||
}
|
||||
}
|
||||
return cachedCatalogState{}
|
||||
}
|
||||
|
||||
now := store.Now().UTC()
|
||||
needsRevalidate := freshness == cache.FreshnessStale || cache.ShouldRevalidate(now, regSnap.SavedAt)
|
||||
runtimeServers := make([]discovery.RuntimeServer, 0, len(regSnap.Servers))
|
||||
existing := make(map[string]bool, len(regSnap.Servers))
|
||||
for _, server := range regSnap.Servers {
|
||||
toolsSnap, toolsFreshness, toolsErr := store.LoadTools(partition, server.Key)
|
||||
if toolsErr != nil || toolsFreshness != cache.FreshnessFresh {
|
||||
@@ -335,10 +374,17 @@ func (l EnvironmentLoader) loadFromCache(store *cache.Store) cachedCatalogState
|
||||
Source: "fresh_cache",
|
||||
Degraded: false,
|
||||
})
|
||||
if id := server.CLI.ID; id != "" {
|
||||
existing[id] = true
|
||||
}
|
||||
if server.Key != "" {
|
||||
existing[server.Key] = true
|
||||
}
|
||||
}
|
||||
if len(runtimeServers) != len(regSnap.Servers) {
|
||||
needsRevalidate = true
|
||||
}
|
||||
runtimeServers = append(runtimeServers, supplementRuntimeServers(existing)...)
|
||||
return cachedCatalogState{
|
||||
Catalog: ir.BuildCatalog(runtimeServers),
|
||||
Registry: regSnap,
|
||||
@@ -347,6 +393,81 @@ func (l EnvironmentLoader) loadFromCache(store *cache.Store) cachedCatalogState
|
||||
}
|
||||
}
|
||||
|
||||
// supplementRuntimeServers materialises the edition.SupplementServers hook
|
||||
// as discovery.RuntimeServer values, skipping IDs that already appear in
|
||||
// the discovery result. The returned servers carry no tools — they exist
|
||||
// only so catalog.FindProduct can resolve an endpoint; tool validation
|
||||
// for these products is expected to fall through to directRuntimeEndpoint.
|
||||
func supplementRuntimeServers(existing map[string]bool) []discovery.RuntimeServer {
|
||||
fn := edition.Get().SupplementServers
|
||||
if fn == nil {
|
||||
return nil
|
||||
}
|
||||
sup := fn()
|
||||
if len(sup) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]discovery.RuntimeServer, 0, len(sup))
|
||||
for _, s := range sup {
|
||||
if s.ID == "" {
|
||||
continue
|
||||
}
|
||||
if existing != nil && existing[s.ID] {
|
||||
continue
|
||||
}
|
||||
out = append(out, discovery.RuntimeServer{
|
||||
Server: editionmerge.ToDescriptor(s, "edition_supplement"),
|
||||
Source: "edition_supplement",
|
||||
Degraded: false,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fallbackRuntimeServers materialises the edition.FallbackServers hook,
|
||||
// additionally folding in SupplementServers entries the hook omits.
|
||||
// Used when every other discovery avenue failed.
|
||||
func fallbackRuntimeServers() []discovery.RuntimeServer {
|
||||
fn := edition.Get().FallbackServers
|
||||
if fn == nil {
|
||||
return supplementRuntimeServers(nil)
|
||||
}
|
||||
fb := fn()
|
||||
if len(fb) == 0 {
|
||||
return supplementRuntimeServers(nil)
|
||||
}
|
||||
existing := make(map[string]bool, len(fb))
|
||||
out := make([]discovery.RuntimeServer, 0, len(fb))
|
||||
for _, s := range fb {
|
||||
if s.ID == "" {
|
||||
continue
|
||||
}
|
||||
existing[s.ID] = true
|
||||
out = append(out, discovery.RuntimeServer{
|
||||
Server: editionmerge.ToDescriptor(s, "edition_fallback"),
|
||||
Source: "edition_fallback",
|
||||
Degraded: false,
|
||||
})
|
||||
}
|
||||
out = append(out, supplementRuntimeServers(existing)...)
|
||||
return out
|
||||
}
|
||||
|
||||
// appendSupplementRuntimeServers merges supplement entries into a live
|
||||
// discovery result, deduplicating against existing IDs.
|
||||
func appendSupplementRuntimeServers(servers []discovery.RuntimeServer) []discovery.RuntimeServer {
|
||||
existing := make(map[string]bool, len(servers))
|
||||
for _, s := range servers {
|
||||
if id := s.Server.CLI.ID; id != "" {
|
||||
existing[id] = true
|
||||
}
|
||||
if s.Server.Key != "" {
|
||||
existing[s.Server.Key] = true
|
||||
}
|
||||
}
|
||||
return append(servers, supplementRuntimeServers(existing)...)
|
||||
}
|
||||
|
||||
func (l EnvironmentLoader) lookup(key string) (string, bool) {
|
||||
if l.LookupEnv == nil {
|
||||
return "", false
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// setEdition overrides the active edition hooks for the duration of the test.
|
||||
func setEdition(t *testing.T, h *edition.Hooks) {
|
||||
t.Helper()
|
||||
prev := edition.Get()
|
||||
edition.Override(h)
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
}
|
||||
|
||||
func seedRegistryCache(t *testing.T, store *cache.Store, partition string, savedAt time.Time, servers []market.ServerDescriptor) {
|
||||
t.Helper()
|
||||
if err := store.SaveRegistry(partition, cache.RegistrySnapshot{
|
||||
SavedAt: savedAt,
|
||||
Servers: servers,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveRegistry(%q) error = %v", partition, err)
|
||||
}
|
||||
for _, server := range servers {
|
||||
if err := store.SaveTools(partition, server.Key, cache.ToolsSnapshot{
|
||||
SavedAt: savedAt,
|
||||
ServerKey: server.Key,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveTools(%q) error = %v", server.Key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFromCache_UsesEditionPartition verifies that loadFromCache reads
|
||||
// from the edition-specific partition (wukong/default) instead of the
|
||||
// historical hardcoded default/default. Before the fix, an entry written to
|
||||
// wukong/default was invisible to the runtime catalog loader — which is
|
||||
// exactly what caused `dws conference meeting create` to report
|
||||
// endpoint_not_resolved while todo succeeded (the open-source Market cache
|
||||
// happened to carry todo).
|
||||
func TestLoadFromCache_UsesEditionPartition(t *testing.T) {
|
||||
setEdition(t, &edition.Hooks{Name: "wukong"})
|
||||
|
||||
root := t.TempDir()
|
||||
now := time.Date(2026, 4, 28, 0, 0, 0, 0, time.UTC)
|
||||
store := cache.NewStore(root)
|
||||
store.Now = func() time.Time { return now }
|
||||
|
||||
seedRegistryCache(t, store, "wukong/default", now, []market.ServerDescriptor{
|
||||
{
|
||||
Key: "conference",
|
||||
DisplayName: "会议",
|
||||
Endpoint: "https://example.invalid/conference",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "conference",
|
||||
Command: "conference",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
loader := EnvironmentLoader{}
|
||||
state := loader.loadFromCache(store)
|
||||
|
||||
if !state.Available {
|
||||
t.Fatalf("expected cached state available; got %+v", state)
|
||||
}
|
||||
if _, ok := state.Catalog.FindProduct("conference"); !ok {
|
||||
t.Fatalf("conference not in catalog; products=%v", productIDs(state.Catalog.Products))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFromCache_IgnoresDefaultPartitionForOverlay asserts the cross-partition
|
||||
// leak is gone: writing servers under default/default while the edition is
|
||||
// Wukong must NOT surface in the runtime catalog. Previously this path was
|
||||
// the accidental fallback that let `dws todo` work on a gray-released host.
|
||||
func TestLoadFromCache_IgnoresDefaultPartitionForOverlay(t *testing.T) {
|
||||
setEdition(t, &edition.Hooks{Name: "wukong"})
|
||||
|
||||
root := t.TempDir()
|
||||
now := time.Date(2026, 4, 28, 0, 0, 0, 0, time.UTC)
|
||||
store := cache.NewStore(root)
|
||||
store.Now = func() time.Time { return now }
|
||||
|
||||
seedRegistryCache(t, store, "default/default", now, []market.ServerDescriptor{
|
||||
{
|
||||
Key: "todo",
|
||||
DisplayName: "待办",
|
||||
Endpoint: "https://example.invalid/todo",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "todo",
|
||||
Command: "todo",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
loader := EnvironmentLoader{}
|
||||
state := loader.loadFromCache(store)
|
||||
|
||||
if state.Available {
|
||||
if _, ok := state.Catalog.FindProduct("todo"); ok {
|
||||
t.Fatalf("todo leaked from default/default into wukong catalog (partition isolation regressed)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFromCache_OpenEdition_UsesDefaultPartition keeps the open-source
|
||||
// core behaviour intact: with edition.Name == "" (zero value), loadFromCache
|
||||
// must still read default/default.
|
||||
func TestLoadFromCache_OpenEdition_UsesDefaultPartition(t *testing.T) {
|
||||
setEdition(t, &edition.Hooks{})
|
||||
|
||||
root := t.TempDir()
|
||||
now := time.Date(2026, 4, 28, 0, 0, 0, 0, time.UTC)
|
||||
store := cache.NewStore(root)
|
||||
store.Now = func() time.Time { return now }
|
||||
|
||||
seedRegistryCache(t, store, "default/default", now, []market.ServerDescriptor{
|
||||
{
|
||||
Key: "doc",
|
||||
DisplayName: "文档",
|
||||
Endpoint: "https://example.invalid/doc",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "doc",
|
||||
Command: "doc",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
loader := EnvironmentLoader{}
|
||||
state := loader.loadFromCache(store)
|
||||
|
||||
if !state.Available {
|
||||
t.Fatalf("expected cached state available for open edition; got %+v", state)
|
||||
}
|
||||
if _, ok := state.Catalog.FindProduct("doc"); !ok {
|
||||
t.Fatalf("doc not in catalog; products=%v", productIDs(state.Catalog.Products))
|
||||
}
|
||||
}
|
||||
|
||||
func productIDs(products []ir.CanonicalProduct) []string {
|
||||
ids := make([]string, 0, len(products))
|
||||
for _, p := range products {
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// TestLoadFromCache_SupplementFillsGaps simulates the Wukong gray-release
|
||||
// scenario: the Portal envelope only carries `live`, but the edition's
|
||||
// SupplementServers hook ships the hardcoded endpoints for `conference` and
|
||||
// `todo`. The resulting catalog must expose all three so runtime endpoint
|
||||
// resolution does not depend on the historical default-partition accident.
|
||||
func TestLoadFromCache_SupplementFillsGaps(t *testing.T) {
|
||||
setEdition(t, &edition.Hooks{
|
||||
Name: "wukong",
|
||||
SupplementServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{ID: "conference", Name: "会议", Endpoint: "https://example.invalid/conference"},
|
||||
{ID: "todo", Name: "待办", Endpoint: "https://example.invalid/todo"},
|
||||
// Duplicate of the discovery entry — MUST be overridden by
|
||||
// the discovery entry (discovery wins on ID collision).
|
||||
{ID: "live", Name: "直播(supplement)", Endpoint: "https://example.invalid/overridden"},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
root := t.TempDir()
|
||||
now := time.Date(2026, 4, 28, 0, 0, 0, 0, time.UTC)
|
||||
store := cache.NewStore(root)
|
||||
store.Now = func() time.Time { return now }
|
||||
|
||||
liveEndpoint := "https://example.invalid/live"
|
||||
seedRegistryCache(t, store, "wukong/default", now, []market.ServerDescriptor{
|
||||
{
|
||||
Key: "live",
|
||||
DisplayName: "直播",
|
||||
Endpoint: liveEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "live",
|
||||
Command: "live",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
loader := EnvironmentLoader{}
|
||||
state := loader.loadFromCache(store)
|
||||
if !state.Available {
|
||||
t.Fatalf("expected cached state available; got %+v", state)
|
||||
}
|
||||
|
||||
wantIDs := map[string]string{
|
||||
"conference": "https://example.invalid/conference",
|
||||
"todo": "https://example.invalid/todo",
|
||||
"live": liveEndpoint, // discovery wins, NOT the supplement's overridden URL
|
||||
}
|
||||
for id, wantEndpoint := range wantIDs {
|
||||
product, ok := state.Catalog.FindProduct(id)
|
||||
if !ok {
|
||||
t.Errorf("catalog missing product %q; have %v", id, productIDs(state.Catalog.Products))
|
||||
continue
|
||||
}
|
||||
if product.Endpoint != wantEndpoint {
|
||||
t.Errorf("product %q endpoint = %q, want %q", id, product.Endpoint, wantEndpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFromCache_EmptyRegistry_StillExposesSupplement covers the cold-start
|
||||
// gray-release case: no cached registry at all, but the edition still knows
|
||||
// about a set of hardcoded products. Those should be exposed via the catalog
|
||||
// so `dws foo bar` does not fail with endpoint_not_resolved on first run.
|
||||
func TestLoadFromCache_EmptyRegistry_StillExposesSupplement(t *testing.T) {
|
||||
setEdition(t, &edition.Hooks{
|
||||
Name: "wukong",
|
||||
SupplementServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{ID: "conference", Name: "会议", Endpoint: "https://example.invalid/conference"},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
root := t.TempDir()
|
||||
now := time.Date(2026, 4, 28, 0, 0, 0, 0, time.UTC)
|
||||
store := cache.NewStore(root)
|
||||
store.Now = func() time.Time { return now }
|
||||
|
||||
loader := EnvironmentLoader{}
|
||||
state := loader.loadFromCache(store)
|
||||
|
||||
if !state.Available {
|
||||
t.Fatalf("expected cached state available via supplement; got %+v", state)
|
||||
}
|
||||
if _, ok := state.Catalog.FindProduct("conference"); !ok {
|
||||
t.Fatalf("supplement did not surface conference into catalog; products=%v", productIDs(state.Catalog.Products))
|
||||
}
|
||||
if !state.NeedsRevalidate {
|
||||
t.Errorf("NeedsRevalidate should be true when only supplement is available")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFallbackRuntimeServers_UsedWhenDiscoveryFailsWithoutCache exercises
|
||||
// the worst-case path: no cached registry AND no live discovery (embedded
|
||||
// scenario where AuthTokenFunc returns empty). FallbackServers must still
|
||||
// surface a usable catalog.
|
||||
func TestFallbackRuntimeServers_UsedWhenDiscoveryFailsWithoutCache(t *testing.T) {
|
||||
setEdition(t, &edition.Hooks{
|
||||
Name: "wukong",
|
||||
FallbackServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{ID: "conference", Name: "会议", Endpoint: "https://fallback.invalid/conference"},
|
||||
}
|
||||
},
|
||||
SupplementServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{ID: "extra", Name: "Extra", Endpoint: "https://fallback.invalid/extra"},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
rs := fallbackRuntimeServers()
|
||||
if len(rs) != 2 {
|
||||
t.Fatalf("fallbackRuntimeServers() len = %d, want 2 (fallback + non-overlapping supplement); got %v", len(rs), rs)
|
||||
}
|
||||
|
||||
ids := make(map[string]bool, len(rs))
|
||||
for _, r := range rs {
|
||||
ids[r.Server.CLI.ID] = true
|
||||
}
|
||||
for _, want := range []string{"conference", "extra"} {
|
||||
if !ids[want] {
|
||||
t.Errorf("fallbackRuntimeServers() missing %q; have %v", want, ids)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package editionmerge converts edition.ServerInfo hooks into
|
||||
// market.ServerDescriptor values and merges them into discovery results.
|
||||
//
|
||||
// This package exists so both internal/cli (runtime catalog loader) and
|
||||
// internal/app (command-tree loader) can apply the edition's
|
||||
// SupplementServers / FallbackServers hooks consistently against the same
|
||||
// discovery pipeline, instead of the hooks being wired only at the
|
||||
// command-tree layer. Keeping the logic here avoids an import cycle
|
||||
// between internal/cli ↔ internal/app.
|
||||
package editionmerge
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// MergeSupplement returns servers augmented with the active edition's
|
||||
// SupplementServers hook. Discovery entries always win on ID collision —
|
||||
// the supplement only fills gaps that discovery did not cover.
|
||||
func MergeSupplement(servers []market.ServerDescriptor) []market.ServerDescriptor {
|
||||
fn := edition.Get().SupplementServers
|
||||
if fn == nil {
|
||||
return servers
|
||||
}
|
||||
existing := make(map[string]bool, len(servers))
|
||||
for _, s := range servers {
|
||||
if id := s.CLI.ID; id != "" {
|
||||
existing[id] = true
|
||||
}
|
||||
if s.Key != "" {
|
||||
existing[s.Key] = true
|
||||
}
|
||||
}
|
||||
for _, sup := range fn() {
|
||||
if sup.ID == "" || existing[sup.ID] {
|
||||
continue
|
||||
}
|
||||
servers = append(servers, ToDescriptor(sup, "edition_supplement"))
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
// FallbackToDescriptors converts the edition's FallbackServers hook into
|
||||
// market.ServerDescriptor values. Callers should only invoke this when
|
||||
// live discovery returned zero servers and the cache is also empty.
|
||||
func FallbackToDescriptors(servers []edition.ServerInfo) []market.ServerDescriptor {
|
||||
out := make([]market.ServerDescriptor, 0, len(servers))
|
||||
for _, s := range servers {
|
||||
out = append(out, ToDescriptor(s, "edition_fallback"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ToDescriptor is the shared conversion from edition.ServerInfo to the
|
||||
// market descriptor shape expected by downstream consumers.
|
||||
//
|
||||
// Source carries the origin tag for diagnostics / metrics. Supplement and
|
||||
// fallback entries intentionally carry no ToolOverrides — that keeps
|
||||
// internal/compat.BuildDynamicCommands from materialising parallel
|
||||
// command trees for products already owned by hardcoded overlays (see
|
||||
// internal/compat/dynamic_commands.go's CLIOverlay gate).
|
||||
func ToDescriptor(s edition.ServerInfo, source string) market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Key: s.ID,
|
||||
DisplayName: s.Name,
|
||||
Endpoint: s.Endpoint,
|
||||
Source: source,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: s.ID,
|
||||
Command: s.ID,
|
||||
Prefixes: s.Prefixes,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package editionmerge
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func restoreEdition(t *testing.T) {
|
||||
t.Helper()
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
}
|
||||
|
||||
func TestMergeSupplement_DiscoveryWinsOnCollision(t *testing.T) {
|
||||
restoreEdition(t)
|
||||
edition.Override(&edition.Hooks{
|
||||
SupplementServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{ID: "conference", Name: "会议", Endpoint: "https://hardcoded/conference"},
|
||||
{ID: "doc", Name: "文档(overridden)", Endpoint: "https://hardcoded/doc"},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
servers := []market.ServerDescriptor{
|
||||
{
|
||||
Key: "doc",
|
||||
DisplayName: "文档",
|
||||
Endpoint: "https://live/doc",
|
||||
CLI: market.CLIOverlay{ID: "doc", Command: "doc"},
|
||||
},
|
||||
}
|
||||
|
||||
merged := MergeSupplement(servers)
|
||||
|
||||
if len(merged) != 2 {
|
||||
t.Fatalf("merged len = %d, want 2", len(merged))
|
||||
}
|
||||
|
||||
byID := make(map[string]market.ServerDescriptor, len(merged))
|
||||
for _, m := range merged {
|
||||
byID[m.CLI.ID] = m
|
||||
}
|
||||
if got := byID["doc"].Endpoint; got != "https://live/doc" {
|
||||
t.Errorf("doc endpoint = %q, want live endpoint (discovery wins)", got)
|
||||
}
|
||||
if got := byID["conference"].Endpoint; got != "https://hardcoded/conference" {
|
||||
t.Errorf("conference endpoint = %q, want supplement endpoint", got)
|
||||
}
|
||||
if got := byID["conference"].Source; got != "edition_supplement" {
|
||||
t.Errorf("conference Source = %q, want edition_supplement", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeSupplement_NilHookIsNoop(t *testing.T) {
|
||||
restoreEdition(t)
|
||||
edition.Override(&edition.Hooks{})
|
||||
|
||||
servers := []market.ServerDescriptor{
|
||||
{Key: "doc", DisplayName: "文档", Endpoint: "https://live/doc",
|
||||
CLI: market.CLIOverlay{ID: "doc", Command: "doc"}},
|
||||
}
|
||||
|
||||
merged := MergeSupplement(servers)
|
||||
|
||||
if len(merged) != 1 {
|
||||
t.Fatalf("merged len = %d, want 1 (no supplement hook registered)", len(merged))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeSupplement_EmptyIDSkipped(t *testing.T) {
|
||||
restoreEdition(t)
|
||||
edition.Override(&edition.Hooks{
|
||||
SupplementServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{ID: "", Name: "empty", Endpoint: "https://example.invalid/empty"},
|
||||
{ID: "valid", Name: "valid", Endpoint: "https://example.invalid/valid"},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
merged := MergeSupplement(nil)
|
||||
if len(merged) != 1 {
|
||||
t.Fatalf("merged len = %d, want 1 (empty ID must be skipped)", len(merged))
|
||||
}
|
||||
if merged[0].CLI.ID != "valid" {
|
||||
t.Errorf("merged[0].CLI.ID = %q, want valid", merged[0].CLI.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackToDescriptors(t *testing.T) {
|
||||
got := FallbackToDescriptors([]edition.ServerInfo{
|
||||
{ID: "conference", Name: "会议", Endpoint: "https://example.invalid/conference", Prefixes: []string{"conference", "meeting"}},
|
||||
})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
g := got[0]
|
||||
if g.CLI.ID != "conference" || g.CLI.Command != "conference" {
|
||||
t.Errorf("CLI overlay not wired: %+v", g.CLI)
|
||||
}
|
||||
if g.Source != "edition_fallback" {
|
||||
t.Errorf("Source = %q, want edition_fallback", g.Source)
|
||||
}
|
||||
if len(g.CLI.ToolOverrides) != 0 {
|
||||
t.Errorf("fallback descriptor must not carry ToolOverrides; got %v", g.CLI.ToolOverrides)
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,10 @@ func (e *Error) Unwrap() error {
|
||||
type Option func(*Error)
|
||||
|
||||
// ExitCode returns the documented process exit code for the error category.
|
||||
// exit=4 is reserved exclusively for PATError (see internal/errors/pat.go
|
||||
// ExitCodePermission and the exit-code table in docs/reference.md);
|
||||
// Discovery therefore uses 6 so hosts can tell "catalog lookup broke"
|
||||
// apart from "PAT permission insufficient".
|
||||
func (e *Error) ExitCode() int {
|
||||
switch e.Category {
|
||||
case CategoryAPI:
|
||||
@@ -74,7 +78,7 @@ func (e *Error) ExitCode() int {
|
||||
case CategoryValidation:
|
||||
return 3
|
||||
case CategoryDiscovery:
|
||||
return 4
|
||||
return 6
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ func TestExitCode_AllCategories(t *testing.T) {
|
||||
{CategoryAPI, 1},
|
||||
{CategoryAuth, 2},
|
||||
{CategoryValidation, 3},
|
||||
{CategoryDiscovery, 4},
|
||||
{CategoryDiscovery, 6},
|
||||
{CategoryInternal, 5},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestExitCodeByCategory(t *testing.T) {
|
||||
{err: NewAPI("api"), want: 1},
|
||||
{err: NewAuth("auth"), want: 2},
|
||||
{err: NewValidation("validation"), want: 3},
|
||||
{err: NewDiscovery("discovery"), want: 4},
|
||||
{err: NewDiscovery("discovery"), want: 6},
|
||||
{err: NewInternal("internal"), want: 5},
|
||||
{err: stderrors.New("plain"), want: 5},
|
||||
}
|
||||
|
||||
+175
-29
@@ -18,15 +18,95 @@ import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// hostControlProvider returns the host-owned clawType for the current
|
||||
// process, or empty string when CLI is in default (CLI-owned) mode.
|
||||
// Injected lazily via SetHostControlProvider to avoid an
|
||||
// internal/errors → internal/auth import cycle.
|
||||
//
|
||||
// Access is serialized by hostControlMu so that tests can swap the provider
|
||||
// without triggering the race detector against parallel classifier callers.
|
||||
var (
|
||||
hostControlMu sync.RWMutex
|
||||
hostControlProvider func() string
|
||||
patBrowserMu sync.RWMutex
|
||||
patBrowserProvider func() bool
|
||||
)
|
||||
|
||||
// SetHostControlProvider wires up the classifier's hostControl injection.
|
||||
// It MUST be called once during CLI bootstrap (e.g. from internal/app
|
||||
// init()) so that the first cleanPATJSON call observes a valid provider.
|
||||
// Passing nil disables injection (useful for isolated tests).
|
||||
func SetHostControlProvider(fn func() string) {
|
||||
hostControlMu.Lock()
|
||||
defer hostControlMu.Unlock()
|
||||
hostControlProvider = fn
|
||||
}
|
||||
|
||||
// SetPATOpenBrowserProvider wires the PAT JSON serializer to the current
|
||||
// browser policy. Passing nil restores the open-source fallback (true).
|
||||
func SetPATOpenBrowserProvider(fn func() bool) {
|
||||
patBrowserMu.Lock()
|
||||
defer patBrowserMu.Unlock()
|
||||
patBrowserProvider = fn
|
||||
}
|
||||
|
||||
// PATOpenBrowserValue returns the effective browser-open recommendation to
|
||||
// embed in PAT JSON payloads. The open-source fallback is true to preserve
|
||||
// historical behavior when no provider is wired.
|
||||
func PATOpenBrowserValue() bool {
|
||||
patBrowserMu.RLock()
|
||||
provider := patBrowserProvider
|
||||
patBrowserMu.RUnlock()
|
||||
if provider == nil {
|
||||
return true
|
||||
}
|
||||
return provider()
|
||||
}
|
||||
|
||||
// HostControlBlock returns the canonical hostControl map injected into
|
||||
// PAT stderr JSON when the CLI is operating in host-owned mode, or nil
|
||||
// when it is not. The returned map is safe for the caller to mutate
|
||||
// because a new map is constructed on each call.
|
||||
//
|
||||
// callbackOwner is kept as a legacy compatibility key for hosts that adopted
|
||||
// it before the contract converged on the hostControl single injection point.
|
||||
func HostControlBlock() map[string]any {
|
||||
hostControlMu.RLock()
|
||||
provider := hostControlProvider
|
||||
hostControlMu.RUnlock()
|
||||
if provider == nil {
|
||||
return nil
|
||||
}
|
||||
claw := provider()
|
||||
if claw == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"clawType": claw,
|
||||
"callbackOwner": "host",
|
||||
"mode": "host",
|
||||
"pollingOwner": "host",
|
||||
"retryOwner": "host",
|
||||
}
|
||||
}
|
||||
|
||||
// ExitCodePermission is the process exit code for PAT authorisation failures.
|
||||
const ExitCodePermission = 4
|
||||
|
||||
// PATError represents a PAT (Personal Action Token) authorization failure
|
||||
// that should be passed through to stderr as raw JSON without any CLI-layer
|
||||
// wrapping. The host application (e.g. RewindDesktop) parses the JSON to
|
||||
// display its own authorisation UI.
|
||||
// wrapping. The host application parses the JSON to display its own
|
||||
// authorization UI. The wire schema is fixed: a single-line, directly
|
||||
// json.Unmarshal-able payload of the form
|
||||
// {"success":false,"code":<frozen enum>,"data":{...}}.
|
||||
//
|
||||
// When the payload includes data.uri, that URL is the authoritative
|
||||
// server-provided authorization link. Hosts must treat it as opaque and open
|
||||
// it verbatim instead of parsing and reconstructing it locally, because
|
||||
// required parameters may live in query, encoded hash, or fragment sections.
|
||||
type PATError struct {
|
||||
RawJSON string
|
||||
}
|
||||
@@ -49,9 +129,21 @@ var patNoPermissionCodes = map[string]bool{
|
||||
}
|
||||
|
||||
// patAuthRequiredCodes are error codes that trigger the PAT authorization
|
||||
// flow (e.g. the server auto-created a CLI app and returned auth details).
|
||||
// flow (e.g. the server auto-created a CLI app and returned auth details,
|
||||
// or the caller's OAuth token lacks a scope that must be re-acquired via
|
||||
// `dws auth login --scope <missing>`).
|
||||
//
|
||||
// Keep keys in alphabetical order so diffs are stable. Both codes below are
|
||||
// part of the frozen PAT-family selector and MUST be surfaced as *PATError
|
||||
// (exit=4) so hosts can act on them:
|
||||
// - AGENT_CODE_NOT_EXISTS: data.agentCode tells the host which agent
|
||||
// registration is missing.
|
||||
// - PAT_SCOPE_AUTH_REQUIRED: data.missingScope tells the host which
|
||||
// OAuth scope to re-acquire via
|
||||
// `dws auth login --scope <data.missingScope>`.
|
||||
var patAuthRequiredCodes = map[string]bool{
|
||||
"AGENT_CODE_NOT_EXISTS": true,
|
||||
"AGENT_CODE_NOT_EXISTS": true,
|
||||
"PAT_SCOPE_AUTH_REQUIRED": true,
|
||||
}
|
||||
|
||||
// IsPATError reports whether err is a *PATError.
|
||||
@@ -65,6 +157,35 @@ func IsPATNoPermissionCode(code string) bool {
|
||||
return patNoPermissionCodes[code]
|
||||
}
|
||||
|
||||
// errCodeKeys is the canonical priority order in which we look up
|
||||
// upstream error code fields. Servers historically rotated between camel
|
||||
// and snake case; we accept all three and pick the first that resolves to
|
||||
// a recognised value.
|
||||
var errCodeKeys = []string{"code", "errorCode", "error_code"}
|
||||
|
||||
// lookupCodeIn returns the first value in body[errCodeKeys] that is a
|
||||
// non-empty string AND is a member of accept. Used by the PAT and DWS
|
||||
// gateway classifiers, which differ only in their accept-set.
|
||||
func lookupCodeIn(body map[string]any, accept map[string]bool) (string, bool) {
|
||||
for _, key := range errCodeKeys {
|
||||
if code, ok := body[key].(string); ok && accept[code] {
|
||||
return code, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// getPATErrorCode extracts any PAT-intercept code from a map. PAT
|
||||
// intercepts include both permission denials and auth-required selectors:
|
||||
// callers on the text/tool-result path must preserve both families as
|
||||
// *PATError so exit=4 + raw stderr JSON survives all the way to the host/CLI.
|
||||
func getPATErrorCode(body map[string]any) (string, bool) {
|
||||
if code, ok := lookupCodeIn(body, patNoPermissionCodes); ok {
|
||||
return code, true
|
||||
}
|
||||
return lookupCodeIn(body, patAuthRequiredCodes)
|
||||
}
|
||||
|
||||
// ---- DWS gateway auth errors (shared between PAT & general auth) ----------
|
||||
|
||||
// dwsGatewayErrors is the set of DWS gateway-level auth error codes.
|
||||
@@ -73,20 +194,18 @@ var dwsGatewayErrors = map[string]bool{
|
||||
"DWS_AUTH_SERVICE_FAILED": true,
|
||||
}
|
||||
|
||||
// getDWSGatewayErrorCode extracts a DWS gateway error code from errBody
|
||||
// (supports both errorCode and error_code field names).
|
||||
// getDWSGatewayErrorCode extracts a DWS gateway error code from errBody.
|
||||
func getDWSGatewayErrorCode(errBody map[string]any) (string, bool) {
|
||||
for _, key := range []string{"errorCode", "error_code"} {
|
||||
if code, ok := errBody[key].(string); ok && dwsGatewayErrors[code] {
|
||||
return code, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
return lookupCodeIn(errBody, dwsGatewayErrors)
|
||||
}
|
||||
|
||||
// isNotLoggedInError checks if the error body indicates missing authentication.
|
||||
func isNotLoggedInError(body map[string]any) bool {
|
||||
if errMsg, ok := body["error"].(string); ok {
|
||||
for _, key := range []string{"error", "message", "errorMsg"} {
|
||||
errMsg, ok := body[key].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(errMsg, "Missing service_id or access_key") {
|
||||
return true
|
||||
}
|
||||
@@ -124,10 +243,8 @@ func ClassifyToolResultContent(content map[string]any) error {
|
||||
WithHint(authExpiredHint()),
|
||||
)
|
||||
}
|
||||
for _, key := range []string{"code", "errorCode"} {
|
||||
if code, ok := content[key].(string); ok && patNoPermissionCodes[code] {
|
||||
return &PATError{RawJSON: cleanPATJSON(content, code)}
|
||||
}
|
||||
if code, ok := getPATErrorCode(content); ok {
|
||||
return &PATError{RawJSON: cleanPATJSON(content, code)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -158,10 +275,8 @@ func ClassifyMCPResponseText(text string) error {
|
||||
)
|
||||
}
|
||||
|
||||
for _, key := range []string{"code", "errorCode"} {
|
||||
if code, ok := body[key].(string); ok && patNoPermissionCodes[code] {
|
||||
return &PATError{RawJSON: cleanPATJSON(body, code)}
|
||||
}
|
||||
if code, ok := getPATErrorCode(body); ok {
|
||||
return &PATError{RawJSON: cleanPATJSON(body, code)}
|
||||
}
|
||||
|
||||
if isBusinessError(body) {
|
||||
@@ -214,12 +329,42 @@ var patTopLevelStrip = map[string]bool{
|
||||
"message": true, "error": true, "trace_id": true, "class": true,
|
||||
}
|
||||
|
||||
// ApplyHostMutations writes the two stderr-JSON fields the host integration
|
||||
// contract requires onto out["data"]:
|
||||
// - data.hostControl: present iff the CLI is in host-owned mode (i.e.
|
||||
// HostControlBlock returns non-nil); legacy data.callbacks is stripped
|
||||
// in the same pass so passive classifier and active retry paths stay
|
||||
// byte-for-byte aligned.
|
||||
// - data.openBrowser: always present; reflects the user's PAT browser
|
||||
// policy.
|
||||
//
|
||||
// Centralizing the two writes here is the single-injection invariant —
|
||||
// any caller that produces a PAT-shaped stderr payload (cleanPATJSON,
|
||||
// active-retry enrichers, scope-required builders) MUST go through this
|
||||
// function instead of writing the fields directly. out["data"] is
|
||||
// promoted to map[string]any if missing or of the wrong type.
|
||||
func ApplyHostMutations(out map[string]any) {
|
||||
data, ok := out["data"].(map[string]any)
|
||||
if !ok || data == nil {
|
||||
data = map[string]any{}
|
||||
out["data"] = data
|
||||
}
|
||||
if block := HostControlBlock(); block != nil {
|
||||
delete(data, "callbacks")
|
||||
data["hostControl"] = block
|
||||
}
|
||||
data["openBrowser"] = PATOpenBrowserValue()
|
||||
}
|
||||
|
||||
func cleanPATJSON(body map[string]any, code string) string {
|
||||
out := map[string]any{
|
||||
"success": false,
|
||||
"code": code,
|
||||
}
|
||||
if data, ok := body["data"]; ok {
|
||||
// Keep data.uri exactly as returned by the service. Host consumers open
|
||||
// that link directly, so local normalization would risk dropping
|
||||
// parameters embedded in query/hash/fragment sections.
|
||||
out["data"] = stripClassFields(data)
|
||||
} else {
|
||||
fallback := map[string]any{}
|
||||
@@ -232,7 +377,12 @@ func cleanPATJSON(body map[string]any, code string) string {
|
||||
out["data"] = stripClassFields(fallback)
|
||||
}
|
||||
}
|
||||
b, err := json.MarshalIndent(out, "", " ")
|
||||
ApplyHostMutations(out)
|
||||
|
||||
// stderr JSON MUST be a single-line, directly json.Unmarshal-able
|
||||
// payload — pretty-printing would break naïve host parsers that read
|
||||
// stderr line-by-line and fail on leading whitespace.
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"success":false,"code":"%s"}`, code)
|
||||
}
|
||||
@@ -244,15 +394,11 @@ func cleanPATJSON(body map[string]any, code string) string {
|
||||
// framework (ClassifyPatAuthCheck / AsPatAuthCheckError).
|
||||
|
||||
// ClassifyPatAuthCheck is the open-source fallback that checks a tool-call
|
||||
// Content map for PAT permission codes and auth-required codes. Returns a
|
||||
// Content map for PAT permission codes and auth-required codes. Returns a
|
||||
// non-nil *PATError when the content carries a recognised PAT/auth error.
|
||||
func ClassifyPatAuthCheck(content map[string]any) *PATError {
|
||||
for _, key := range []string{"code", "errorCode"} {
|
||||
if code, ok := content[key].(string); ok {
|
||||
if patNoPermissionCodes[code] || patAuthRequiredCodes[code] {
|
||||
return &PATError{RawJSON: cleanPATJSON(content, code)}
|
||||
}
|
||||
}
|
||||
if code, ok := getPATErrorCode(content); ok {
|
||||
return &PATError{RawJSON: cleanPATJSON(content, code)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+298
-2
@@ -14,6 +14,7 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -143,8 +144,17 @@ func TestIsNotLoggedInError_False(t *testing.T) {
|
||||
func TestIsNotLoggedInError_NoErrorField(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{"message": "Missing service_id or access_key"}
|
||||
if isNotLoggedInError(body) {
|
||||
t.Fatal("expected false when error field is absent")
|
||||
if !isNotLoggedInError(body) {
|
||||
t.Fatal("expected true when equivalent auth message is present in message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDWSGatewayErrorCode_CodeField(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{"code": "DWS_SERVICE_UNAUTHORIZED"}
|
||||
code, ok := getDWSGatewayErrorCode(body)
|
||||
if !ok || code != "DWS_SERVICE_UNAUTHORIZED" {
|
||||
t.Fatalf("getDWSGatewayErrorCode() = (%q, %t), want DWS_SERVICE_UNAUTHORIZED, true", code, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +244,44 @@ func TestClassifyToolResultContent_PATPermission(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyToolResultContent_PATPermissionLegacyErrorCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{
|
||||
"error_code": "PAT_LOW_RISK_NO_PERMISSION",
|
||||
"data": map[string]any{"desc": "需要授权"},
|
||||
}
|
||||
err := ClassifyToolResultContent(content)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for legacy error_code PAT permission")
|
||||
}
|
||||
var patErr *PATError
|
||||
if !stderrors.As(err, &patErr) {
|
||||
t.Fatalf("expected *PATError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "PAT_LOW_RISK_NO_PERMISSION") {
|
||||
t.Errorf("RawJSON should contain PAT_LOW_RISK_NO_PERMISSION, got: %s", patErr.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyToolResultContent_PATAuthRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{
|
||||
"errorCode": "AGENT_CODE_NOT_EXISTS",
|
||||
"data": map[string]any{"agentCode": "agt-missing"},
|
||||
}
|
||||
err := ClassifyToolResultContent(content)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for PAT auth-required selector")
|
||||
}
|
||||
var patErr *PATError
|
||||
if !stderrors.As(err, &patErr) {
|
||||
t.Fatalf("expected *PATError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "AGENT_CODE_NOT_EXISTS") {
|
||||
t.Errorf("RawJSON should contain AGENT_CODE_NOT_EXISTS, got: %s", patErr.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyToolResultContent_NoError(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{"success": true, "data": "ok"}
|
||||
@@ -294,6 +342,41 @@ func TestClassifyMCPResponseText_PATPermission(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyMCPResponseText_PATPermissionLegacyErrorCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := `{"error_code":"PAT_MEDIUM_RISK_NO_PERMISSION","data":{"desc":"legacy"}}`
|
||||
err := ClassifyMCPResponseText(text)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
var patErr *PATError
|
||||
if !stderrors.As(err, &patErr) {
|
||||
t.Fatalf("expected *PATError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "PAT_MEDIUM_RISK_NO_PERMISSION") {
|
||||
t.Errorf("RawJSON should contain legacy code, got: %s", patErr.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyMCPResponseText_PATAuthRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED","data":{"missingScope":"mail:send"}}`
|
||||
err := ClassifyMCPResponseText(text)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
var patErr *PATError
|
||||
if !stderrors.As(err, &patErr) {
|
||||
t.Fatalf("expected *PATError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "PAT_SCOPE_AUTH_REQUIRED") {
|
||||
t.Errorf("RawJSON should contain PAT_SCOPE_AUTH_REQUIRED, got: %s", patErr.RawJSON)
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "missingScope") {
|
||||
t.Errorf("RawJSON should preserve missingScope, got: %s", patErr.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyMCPResponseText_BusinessError(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := `{"success":false,"errorMsg":"搜索内容不能为空"}`
|
||||
@@ -345,6 +428,18 @@ func TestClassifyPatAuthCheck_PATNoPermission(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPatAuthCheck_LegacyErrorCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{"error_code": "PAT_HIGH_RISK_NO_PERMISSION", "data": map[string]any{"flowId": "f1"}}
|
||||
patErr := ClassifyPatAuthCheck(content)
|
||||
if patErr == nil {
|
||||
t.Fatal("expected non-nil *PATError for legacy error_code")
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "PAT_HIGH_RISK_NO_PERMISSION") {
|
||||
t.Errorf("RawJSON should contain PAT_HIGH_RISK_NO_PERMISSION, got: %s", patErr.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPatAuthCheck_AgentCodeNotExists(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{"errorCode": "AGENT_CODE_NOT_EXISTS", "data": map[string]any{"clientId": "c1"}}
|
||||
@@ -357,6 +452,43 @@ func TestClassifyPatAuthCheck_AgentCodeNotExists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyPatAuthCheck_scope_auth_required pins the PAT_SCOPE_AUTH_REQUIRED
|
||||
// selector (part of the frozen PAT-family enum; see patAuthRequiredCodes in
|
||||
// internal/errors/pat.go) as a PATError with exit=4 so hosts can kick the
|
||||
// `dws auth login --scope <data.missingScope>` branch.
|
||||
func TestClassifyPatAuthCheck_scope_auth_required(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_SCOPE_AUTH_REQUIRED",
|
||||
"data": map[string]any{"missingScope": "mail:send"},
|
||||
}
|
||||
patErr := ClassifyPatAuthCheck(content)
|
||||
if patErr == nil {
|
||||
t.Fatal("expected non-nil *PATError for PAT_SCOPE_AUTH_REQUIRED")
|
||||
}
|
||||
|
||||
// Error value MUST satisfy the ExitCoder contract (exit=4) so the
|
||||
// process exits with the PAT Frozen code regardless of wrapping.
|
||||
var ec interface{ ExitCode() int } = patErr
|
||||
if ec.ExitCode() != ExitCodePermission {
|
||||
t.Errorf("ExitCode() = %d, want %d", ec.ExitCode(), ExitCodePermission)
|
||||
}
|
||||
|
||||
// Host-visible RawStderr must carry the selector and, crucially, the
|
||||
// missingScope field that drives `dws auth login --scope <x>`.
|
||||
raw := patErr.RawStderr()
|
||||
if !strings.Contains(raw, "PAT_SCOPE_AUTH_REQUIRED") {
|
||||
t.Errorf("RawStderr missing selector, got: %s", raw)
|
||||
}
|
||||
if !strings.Contains(raw, "missingScope") {
|
||||
t.Errorf("RawStderr missing missingScope field, got: %s", raw)
|
||||
}
|
||||
if !strings.Contains(raw, "mail:send") {
|
||||
t.Errorf("RawStderr missing missingScope value, got: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPatAuthCheck_NoMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{"code": "SOME_BUSINESS_ERROR", "message": "oops"}
|
||||
@@ -423,6 +555,9 @@ func TestCleanPATJSON_WithData(t *testing.T) {
|
||||
if strings.Contains(result, "class") {
|
||||
t.Errorf("expected class field to be stripped, got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, `"openBrowser":true`) {
|
||||
t.Errorf("expected openBrowser default in output, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPATJSON_WithoutData(t *testing.T) {
|
||||
@@ -443,6 +578,167 @@ func TestCleanPATJSON_WithoutData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanPATJSON_InjectsHostControlWhenClawSet verifies the
|
||||
// single-injection invariant: when the bootstrap wires a non-empty
|
||||
// clawType provider, cleanPATJSON MUST emit data.hostControl.
|
||||
func TestCleanPATJSON_InjectsHostControlWhenClawSet(t *testing.T) {
|
||||
// Not parallel: mutates the package-level provider.
|
||||
t.Cleanup(func() { SetHostControlProvider(nil) })
|
||||
SetHostControlProvider(func() string { return "my-copilot" })
|
||||
|
||||
body := map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"desc": "需要授权",
|
||||
"flowId": "f-1",
|
||||
"callbacks": []any{"cb1", "cb2"},
|
||||
},
|
||||
}
|
||||
raw := cleanPATJSON(body, "PAT_NO_PERMISSION")
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, raw)
|
||||
}
|
||||
data, ok := parsed["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected data object, got %T", parsed["data"])
|
||||
}
|
||||
hc, ok := data["hostControl"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected data.hostControl to be a map, got %T\nraw=%s", data["hostControl"], raw)
|
||||
}
|
||||
if got, _ := hc["clawType"].(string); got != "my-copilot" {
|
||||
t.Errorf("hostControl.clawType = %q, want %q", got, "my-copilot")
|
||||
}
|
||||
if got, _ := hc["callbackOwner"].(string); got != "host" {
|
||||
t.Errorf("hostControl.callbackOwner = %q, want %q", got, "host")
|
||||
}
|
||||
if got, _ := hc["mode"].(string); got != "host" {
|
||||
t.Errorf("hostControl.mode = %q, want %q", got, "host")
|
||||
}
|
||||
if _, ok := data["callbacks"]; ok {
|
||||
t.Fatalf("cleanPATJSON should strip callbacks in host-owned mode, got: %v", data["callbacks"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanPATJSON_OmitsHostControlByDefault verifies that cleanPATJSON
|
||||
// does NOT include a hostControl block when the provider is unset or
|
||||
// returns empty (default CLI-owned mode).
|
||||
func TestCleanPATJSON_OmitsHostControlByDefault(t *testing.T) {
|
||||
// Not parallel: reads the package-level provider.
|
||||
t.Cleanup(func() { SetHostControlProvider(nil) })
|
||||
SetHostControlProvider(nil)
|
||||
|
||||
body := map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"desc": "need auth",
|
||||
},
|
||||
}
|
||||
raw := cleanPATJSON(body, "PAT_NO_PERMISSION")
|
||||
if strings.Contains(raw, `"hostControl"`) {
|
||||
t.Fatalf("cleanPATJSON should omit hostControl in default mode, got: %s", raw)
|
||||
}
|
||||
|
||||
SetHostControlProvider(func() string { return "" })
|
||||
raw = cleanPATJSON(body, "PAT_NO_PERMISSION")
|
||||
if strings.Contains(raw, `"hostControl"`) {
|
||||
t.Fatalf("cleanPATJSON should omit hostControl when provider returns empty, got: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPATJSON_UsesBrowserPolicyProvider(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
SetHostControlProvider(nil)
|
||||
SetPATOpenBrowserProvider(nil)
|
||||
})
|
||||
SetHostControlProvider(nil)
|
||||
SetPATOpenBrowserProvider(func() bool { return false })
|
||||
|
||||
body := map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"desc": "need auth",
|
||||
},
|
||||
}
|
||||
raw := cleanPATJSON(body, "PAT_NO_PERMISSION")
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, raw)
|
||||
}
|
||||
data, _ := parsed["data"].(map[string]any)
|
||||
if got, ok := data["openBrowser"].(bool); !ok || got {
|
||||
t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanPATJSON_SingleLineOutput pins down the wire invariant: stderr
|
||||
// JSON MUST be emitted as a single line (no embedded \n, no pretty-print
|
||||
// indentation) so that naïve host parsers reading stderr line-by-line stay
|
||||
// correct. Regression guard against accidental reintroduction of
|
||||
// json.MarshalIndent.
|
||||
func TestCleanPATJSON_SingleLineOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_LOW_RISK_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"requiredScopes": []any{"aitable.record:read"},
|
||||
"grantOptions": []any{"session", "permanent"},
|
||||
"displayName": "读取记录",
|
||||
"productName": "AI 表格",
|
||||
},
|
||||
}
|
||||
raw := cleanPATJSON(body, "PAT_LOW_RISK_NO_PERMISSION")
|
||||
|
||||
if strings.Contains(raw, "\n") {
|
||||
t.Fatalf("cleanPATJSON output must be single-line, got embedded newline:\n%s", raw)
|
||||
}
|
||||
if strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t") {
|
||||
t.Fatalf("cleanPATJSON output must not be indented, got leading whitespace: %q", raw)
|
||||
}
|
||||
|
||||
// Contract: the payload must remain a directly json.Unmarshal-able
|
||||
// object, even after the single-line constraint is enforced.
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
t.Fatalf("single-line output must round-trip via json.Unmarshal: %v\nraw=%s", err, raw)
|
||||
}
|
||||
if code, _ := parsed["code"].(string); code != "PAT_LOW_RISK_NO_PERMISSION" {
|
||||
t.Errorf("code = %q, want %q", code, "PAT_LOW_RISK_NO_PERMISSION")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) {
|
||||
t.Parallel()
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D50dff7654b7444e88ced7489b07cce8d%26userCode%3DQ8RY-X6E9#/personalAuthorization?flowId=50dff7654b7444e88ced7489b07cce8d&userCode=Q8RY-X6E9"
|
||||
body := map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_MEDIUM_RISK_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"desc": "在浏览器中打开以下链接进行认证",
|
||||
"flowId": "50dff7654b7444e88ced7489b07cce8d",
|
||||
"uri": rawURI,
|
||||
},
|
||||
}
|
||||
|
||||
result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION")
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
|
||||
t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result)
|
||||
}
|
||||
data, _ := parsed["data"].(map[string]any)
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stripClassFields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -268,15 +268,20 @@ func newAttendanceShiftListCommand(runner executor.Runner) *cobra.Command {
|
||||
|
||||
func newAttendanceSummaryCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "summary",
|
||||
Short: "查询某个人的考勤统计摘要",
|
||||
Long: "查询某个人的考勤统计摘要。--user 与 --date 均必填。",
|
||||
Example: ` dws attendance summary --user USER_ID --date "2026-03-12 15:00:00"`,
|
||||
Use: "summary",
|
||||
Short: "查询某个人的考勤统计摘要",
|
||||
Long: `查询某个人的考勤统计摘要。
|
||||
|
||||
--user、--date、--stats-type 均必填。
|
||||
钉钉服务端业务层强制要求 --stats-type(week/month),不填会返回 C0002 统计类型错误。`,
|
||||
Example: ` dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type month
|
||||
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type week`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
userID, _ := cmd.Flags().GetString("user")
|
||||
workDateStr, _ := cmd.Flags().GetString("date")
|
||||
statsType, _ := cmd.Flags().GetString("stats-type")
|
||||
if userID == "" {
|
||||
return apperrors.NewValidation("--user is required, provide DingTalk user ID")
|
||||
}
|
||||
@@ -287,10 +292,17 @@ func newAttendanceSummaryCommand(runner executor.Runner) *cobra.Command {
|
||||
if err != nil {
|
||||
return apperrors.NewValidation("--date format error, use yyyy-MM-dd HH:mm:ss")
|
||||
}
|
||||
if statsType == "" {
|
||||
return apperrors.NewValidation(`--stats-type is required (week|month), enforced by DingTalk server`)
|
||||
}
|
||||
if statsType != "week" && statsType != "month" {
|
||||
return apperrors.NewValidation(`--stats-type must be "week" or "month"`)
|
||||
}
|
||||
// Build nested structure QueryUserAttendVO
|
||||
vo := map[string]any{
|
||||
"userId": userID,
|
||||
"queryDate": workDateStr,
|
||||
"statsType": statsType,
|
||||
}
|
||||
params := map[string]any{
|
||||
"QueryUserAttendVO": vo,
|
||||
@@ -311,6 +323,7 @@ func newAttendanceSummaryCommand(runner executor.Runner) *cobra.Command {
|
||||
}
|
||||
cmd.Flags().String("user", "", "钉钉用户 ID(必填)")
|
||||
cmd.Flags().String("date", "", "工作日期,格式 yyyy-MM-dd HH:mm:ss,如 2026-03-12 15:00:00(必填)")
|
||||
cmd.Flags().String("stats-type", "", "统计类型:week(周统计)或 month(月统计)(必填,钉钉服务端业务层强制要求)")
|
||||
preferLegacyLeaf(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
+67
-27
@@ -92,11 +92,14 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
|
||||
--open-dingtalk-id 指定 openDingTalkId 发单聊 (适用于无法获取 userId 的场景)。
|
||||
三者只能选其一,不能同时指定。
|
||||
|
||||
消息内容通过 --text 传入,也可作为位置参数;支持 Markdown。可选 --title 作为消息标题。`,
|
||||
消息内容通过 --text 传入,也可作为位置参数;支持 Markdown。必须提供 --title 作为消息标题。
|
||||
|
||||
群聊场景下可用 --at-all / --at-users / --at-mobiles 进行 @ 提醒(仅 --group 时生效)。
|
||||
注意 --text 中需包含对应的 <@userId> / <@all> 占位符才能在客户端渲染出 @ 效果。`,
|
||||
Example: ` dws chat message send --group <openconversation_id> --text "hello"
|
||||
dws chat message send --user <userId> --text "请查收"
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --title "提醒" --text "请确认"
|
||||
dws chat message send --group <openconversation_id> "hello"`,
|
||||
dws chat message send --group <openconversation_id> --title "拉群通知" --text "<@uid> 你被 @ 了" --at-users uid`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -125,6 +128,9 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd.Flags().String("open-dingtalk-id", "", "接收人 openDingTalkId (单聊三选一)")
|
||||
cmd.Flags().String("text", "", "消息内容,支持 Markdown (也可作位置参数)")
|
||||
cmd.Flags().String("title", "", "消息标题 (可选)")
|
||||
cmd.Flags().Bool("at-all", false, "@所有人 (仅 --group 群聊生效)")
|
||||
cmd.Flags().String("at-users", "", "按 userId @ 指定成员,逗号分隔 (仅 --group 群聊生效)")
|
||||
cmd.Flags().String("at-mobiles", "", "按手机号 @ 指定成员,逗号分隔 (仅 --group 群聊生效)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -181,6 +187,15 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
return nil, "", apperrors.NewValidation("--text (or positional argument) is required")
|
||||
}
|
||||
|
||||
atAll, _ := cmd.Flags().GetBool("at-all")
|
||||
atUsers, _ := cmd.Flags().GetString("at-users")
|
||||
atMobiles, _ := cmd.Flags().GetString("at-mobiles")
|
||||
hasAtUsers := strings.TrimSpace(atUsers) != ""
|
||||
hasAtMobiles := strings.TrimSpace(atMobiles) != ""
|
||||
if !hasGroup && (atAll || hasAtUsers || hasAtMobiles) {
|
||||
return nil, "", apperrors.NewValidation("--at-all / --at-users / --at-mobiles only apply when --group is set")
|
||||
}
|
||||
|
||||
params := map[string]any{"text": text}
|
||||
if strings.TrimSpace(title) != "" {
|
||||
params["title"] = title
|
||||
@@ -189,6 +204,15 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
switch {
|
||||
case hasGroup:
|
||||
params["openConversation_id"] = group
|
||||
if atAll {
|
||||
params["isAtAll"] = true
|
||||
}
|
||||
if hasAtUsers {
|
||||
params["atUserIds"] = splitCSV(atUsers)
|
||||
}
|
||||
if hasAtMobiles {
|
||||
params["atMobiles"] = splitCSV(atMobiles)
|
||||
}
|
||||
return params, "send_message_as_user", nil
|
||||
case hasUser:
|
||||
params["receiverUserId"] = user
|
||||
@@ -228,9 +252,9 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
|
||||
preferLegacyLeaf(cmd)
|
||||
|
||||
cmd.Flags().String("group", "", "群会话 openConversationId (群聊必填)")
|
||||
cmd.Flags().String("robot-code", "", "机器人 Code")
|
||||
cmd.Flags().String("text", "", "消息内容 (Markdown)")
|
||||
cmd.Flags().String("title", "", "消息标题")
|
||||
cmd.Flags().String("robot-code", "", "机器人 Code (必填)")
|
||||
cmd.Flags().String("text", "", "消息内容 Markdown (必填)")
|
||||
cmd.Flags().String("title", "", "消息标题 (必填)")
|
||||
cmd.Flags().String("users", "", "接收者 userId 列表,逗号分隔,最多 20 个 (单聊必填)")
|
||||
return cmd
|
||||
}
|
||||
@@ -298,13 +322,18 @@ func newChatGroupCommand(runner executor.Runner) *cobra.Command {
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: newChatGroupMembersListRunE(runner),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
members.Flags().String("id", "", "群 ID / openconversation_id (必填)")
|
||||
members.Flags().String("cursor", "", "分页游标")
|
||||
// Keeps the helper-restructured group winning over the dynamic envelope's
|
||||
// `members` leaf (which only exposes `get_group_members`); without this
|
||||
// the merge layer treats the shape mismatch as "envelope is authority"
|
||||
// and drops the entire helper subtree (issue #164).
|
||||
preferLegacyLeaf(members)
|
||||
|
||||
members.AddCommand(
|
||||
newChatGroupMembersListCommand(runner),
|
||||
newChatGroupMemberAddCommand(runner),
|
||||
newChatGroupMemberRemoveCommand(runner),
|
||||
newChatGroupMembersAddBotCommand(runner),
|
||||
@@ -675,26 +704,37 @@ func newChatMessageSendByWebhookCommand(runner executor.Runner) *cobra.Command {
|
||||
|
||||
// ── group members list ─────────────────────────────────────
|
||||
|
||||
func newChatGroupMembersListRunE(runner executor.Runner) func(*cobra.Command, []string) error {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
groupID, _ := cmd.Flags().GetString("id")
|
||||
if strings.TrimSpace(groupID) == "" {
|
||||
return apperrors.NewValidation("--id is required")
|
||||
}
|
||||
params := map[string]any{
|
||||
"openconversation_id": groupID,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("cursor"); v != "" {
|
||||
params["cursor"] = v
|
||||
}
|
||||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "chat", "get_group_members", params,
|
||||
))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
func newChatGroupMembersListCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "查询群成员列表",
|
||||
Example: ` dws chat group members list --id <openconversation_id>`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
groupID, _ := cmd.Flags().GetString("id")
|
||||
if strings.TrimSpace(groupID) == "" {
|
||||
return apperrors.NewValidation("--id is required")
|
||||
}
|
||||
params := map[string]any{
|
||||
"openconversation_id": groupID,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("cursor"); v != "" {
|
||||
params["cursor"] = v
|
||||
}
|
||||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "chat", "get_group_members", params,
|
||||
))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
|
||||
cmd.Flags().String("cursor", "", "分页游标 (首页留空)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── group rename ───────────────────────────────────────────
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type captureRunner struct {
|
||||
@@ -151,6 +152,216 @@ func TestChatMessageSendRejectsInvalidDestination(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMessageSendForwardsAtMentions guards the regression introduced
|
||||
// alongside the destination-based routing in PR #170: the hardcoded helper
|
||||
// declared --group / --user / --open-dingtalk-id / --text / --title but
|
||||
// dropped the v1.0.15 envelope's --at-users / --at-all / --at-mobiles flags,
|
||||
// so `dws chat message send --group ... --at-users ...` failed with
|
||||
// `unknown flag: --at-users` (issue #177).
|
||||
func TestChatMessageSendForwardsAtMentions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantParams map[string]any
|
||||
}{
|
||||
{
|
||||
name: "group-with-at-users",
|
||||
args: []string{
|
||||
"--group", "cid-xyz",
|
||||
"--title", "拉群通知",
|
||||
"--text", "<@uid-1> <@uid-2> 请关注",
|
||||
"--at-users", "uid-1,uid-2",
|
||||
},
|
||||
wantParams: map[string]any{
|
||||
"openConversation_id": "cid-xyz",
|
||||
"title": "拉群通知",
|
||||
"text": "<@uid-1> <@uid-2> 请关注",
|
||||
"atUserIds": []any{"uid-1", "uid-2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "group-with-at-all",
|
||||
args: []string{
|
||||
"--group", "cid-xyz",
|
||||
"--title", "全员通知",
|
||||
"--text", "<@all> 请关注",
|
||||
"--at-all",
|
||||
},
|
||||
wantParams: map[string]any{
|
||||
"openConversation_id": "cid-xyz",
|
||||
"title": "全员通知",
|
||||
"text": "<@all> 请关注",
|
||||
"isAtAll": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "group-with-at-mobiles",
|
||||
args: []string{
|
||||
"--group", "cid-xyz",
|
||||
"--title", "提醒",
|
||||
"--text", "请 13800000000 确认",
|
||||
"--at-mobiles", "13800000000,13900000000",
|
||||
},
|
||||
wantParams: map[string]any{
|
||||
"openConversation_id": "cid-xyz",
|
||||
"title": "提醒",
|
||||
"text": "请 13800000000 确认",
|
||||
"atMobiles": []any{"13800000000", "13900000000"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
cmd := newChatMessageSendCommand(runner)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs(tc.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if got := runner.last.Tool; got != "send_message_as_user" {
|
||||
t.Fatalf("Tool = %q, want send_message_as_user", got)
|
||||
}
|
||||
for key, want := range tc.wantParams {
|
||||
got, ok := runner.last.Params[key]
|
||||
if !ok {
|
||||
t.Fatalf("Params missing %q; got %#v", key, runner.last.Params)
|
||||
}
|
||||
if !equalAny(got, want) {
|
||||
t.Fatalf("Params[%q] = %#v, want %#v", key, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMessageSendRejectsAtMentionsOutsideGroup ensures we do not silently
|
||||
// drop user intent when --at-* is combined with --user / --open-dingtalk-id
|
||||
// (single-chat tools have no @-mention semantics, so the flag would never
|
||||
// take effect — fail loudly instead of swallowing).
|
||||
func TestChatMessageSendRejectsAtMentionsOutsideGroup(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "user-with-at-users",
|
||||
args: []string{"--user", "034766", "--text", "hi", "--at-users", "uid-1"},
|
||||
},
|
||||
{
|
||||
name: "open-dingtalk-id-with-at-all",
|
||||
args: []string{"--open-dingtalk-id", "OP123", "--text", "hi", "--at-all"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
cmd := newChatMessageSendCommand(runner)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs(tc.args)
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil; output: %s", out.String())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "only apply when --group is set") {
|
||||
t.Fatalf("error = %q, want '...only apply when --group is set'", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func equalAny(a, b any) bool {
|
||||
switch av := a.(type) {
|
||||
case []any:
|
||||
bv, ok := b.([]any)
|
||||
if !ok || len(av) != len(bv) {
|
||||
return false
|
||||
}
|
||||
for i := range av {
|
||||
if av[i] != bv[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return a == b
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatGroupMembersListSubcommand pins the explicit `list` subcommand
|
||||
// added for issue #164: previously the bare `chat group members --id` was
|
||||
// the list path, but it shape-mismatched the dynamic envelope's `members`
|
||||
// leaf and got eaten by the merge layer. Now `dws chat group members list
|
||||
// --id <cid>` is a proper leaf siblings of add/remove/add-bot.
|
||||
func TestChatGroupMembersListSubcommand(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
groupCmd := newChatGroupCommand(runner)
|
||||
var members *cobra.Command
|
||||
for _, sub := range groupCmd.Commands() {
|
||||
if sub.Name() == "members" {
|
||||
members = sub
|
||||
break
|
||||
}
|
||||
}
|
||||
if members == nil {
|
||||
t.Fatalf("members subcommand missing under chat group")
|
||||
}
|
||||
|
||||
want := map[string]bool{"list": false, "add": false, "remove": false, "add-bot": false}
|
||||
for _, leaf := range members.Commands() {
|
||||
if _, ok := want[leaf.Name()]; ok {
|
||||
want[leaf.Name()] = true
|
||||
}
|
||||
}
|
||||
for name, seen := range want {
|
||||
if !seen {
|
||||
t.Errorf("expected `chat group members %s` subcommand, missing", name)
|
||||
}
|
||||
}
|
||||
|
||||
if members.Flags().Lookup("id") != nil {
|
||||
t.Errorf("members container should not declare --id (moved to `list` subcommand to avoid shape-mismatch with dynamic envelope)")
|
||||
}
|
||||
|
||||
var listCmd *cobra.Command
|
||||
for _, leaf := range members.Commands() {
|
||||
if leaf.Name() == "list" {
|
||||
listCmd = leaf
|
||||
break
|
||||
}
|
||||
}
|
||||
if listCmd == nil {
|
||||
t.Fatalf("`list` subcommand not found")
|
||||
}
|
||||
if listCmd.Flags().Lookup("id") == nil {
|
||||
t.Errorf("`list` subcommand must declare --id")
|
||||
}
|
||||
if listCmd.Flags().Lookup("cursor") == nil {
|
||||
t.Errorf("`list` subcommand must declare --cursor")
|
||||
}
|
||||
|
||||
// Drive execution via the group root so cobra resolves the subcommand
|
||||
// path properly (calling Execute() on a child directly would re-enter
|
||||
// the root help branch).
|
||||
var out bytes.Buffer
|
||||
groupCmd.SetOut(&out)
|
||||
groupCmd.SetErr(&out)
|
||||
groupCmd.SetArgs([]string{"members", "list", "--id", "cid-xyz"})
|
||||
if err := groupCmd.Execute(); err != nil {
|
||||
t.Fatalf("members list Execute error = %v\noutput: %s", err, out.String())
|
||||
}
|
||||
if got := runner.last.Tool; got != "get_group_members" {
|
||||
t.Fatalf("Tool = %q, want get_group_members", got)
|
||||
}
|
||||
if got := runner.last.Params["openconversation_id"]; got != "cid-xyz" {
|
||||
t.Fatalf("openconversation_id = %#v, want cid-xyz", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageSendByBotRoutesToBotProduct(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -204,8 +204,18 @@ func newReportCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
Use: "create",
|
||||
Short: "创建日志",
|
||||
Long: `按模版创建一条日志。--contents 为 JSON 数组,每项需含 key、sort、content、contentType、type,
|
||||
与远程 create_report 一致;可先通过 report template list / template detail 取得 templateId 与控件定义。`,
|
||||
Example: ` dws report create --template-id TPL_ID --contents '[{"content":"完成开发","sort":"0","key":"今日完成","contentType":"markdown","type":"1"}]'
|
||||
与远程 create_report 一致;可先通过 report template list / template detail 取得 templateId 与控件定义。
|
||||
|
||||
注意:每个 contents 项的 key 必须精确等于模板的 field_name(中文/英文逐字匹配,不是控件 ID 或别名)。
|
||||
key 与 field_name 不一致时钉钉 API 会返回 SYSTEM_ERROR (success=false),CLI 层不会预先拦截。
|
||||
请先用 report template detail 查到 report_template_fields[].field_name 后再填。`,
|
||||
Example: ` # Step 1:查模板,取 report_template_fields[].field_name 当作 contents[].key
|
||||
dws report template detail --name "周报"
|
||||
|
||||
# Step 2:用上一步拿到的 field_name 作为 key 创建日志
|
||||
dws report create --template-id TPL_ID --contents '[{"key":"<field_name>","sort":"0","content":"完成开发","contentType":"markdown","type":"1"}]'
|
||||
|
||||
# 同时通知到接收人单聊
|
||||
dws report create --template-id TPL_ID --contents '[...]' --to-chat --to-user-ids userId1,userId2`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
@@ -251,7 +261,7 @@ func newReportCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("template-id", "", "日志模版 ID (必填)")
|
||||
cmd.Flags().String("contents", "", "日志内容 JSON 数组 (必填),每项含 key/sort/content/contentType/type")
|
||||
cmd.Flags().String("contents", "", "日志内容 JSON 数组 (必填),每项含 key/sort/content/contentType/type;key 必须精确等于模板 field_name (用 report template detail --name <模板名> 查询)")
|
||||
cmd.Flags().String("dd-from", "dws", "创建来源标识")
|
||||
cmd.Flags().Bool("to-chat", false, "是否发送到日志接收人单聊")
|
||||
cmd.Flags().String("to-user-ids", "", "接收人 userId,逗号分隔 (可选)")
|
||||
|
||||
@@ -83,7 +83,7 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
Example: ` dws todo task create --title "修复线上Bug" --executors userId1,userId2 --priority 40
|
||||
dws todo task create --title "提交报告" --executors userId1 --due "2026-03-10T18:00:00+08:00"
|
||||
|
||||
# 查询 userId: dws contact user search --keyword "姓名"`,
|
||||
# 查询 userId: dws contact user search --query "姓名"`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -135,7 +135,7 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
preferLegacyLeaf(cmd)
|
||||
|
||||
cmd.Flags().String("title", "", i18n.T("待办标题 (必填)"))
|
||||
cmd.Flags().String("executors", "", i18n.T("执行者 userId 列表 (必填)"))
|
||||
cmd.Flags().String("executors", "", i18n.T("执行者 userId 列表,逗号分隔 (必填)。注意: 此处是通讯录 userId,可通过 dws contact user search --query 姓名 查询"))
|
||||
cmd.Flags().String("due", "", i18n.T("截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)"))
|
||||
cmd.Flags().String("priority", "", i18n.T("优先级: 10低/20普通/30较高/40紧急"))
|
||||
cmd.Flags().String("recurrence", "", i18n.T("循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1"))
|
||||
@@ -152,8 +152,20 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
|
||||
func newTodoTaskListCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: i18n.T("查询待办列表"),
|
||||
Use: "list",
|
||||
Short: i18n.T("查询待办列表"),
|
||||
Long: i18n.T(`查询当前用户在当前企业的待办列表。
|
||||
|
||||
覆盖范围:
|
||||
返回当前用户作为"执行者"(executor) 的待办。
|
||||
仅参与但不执行的待办、自己创建但交给他人执行的待办不在返回范围内。
|
||||
|
||||
当前列表能力面向"个人待办",即钉钉待办模块中展示的待办任务,
|
||||
不包含 OA 审批流待办、Teambition 项目任务等其他业务线的待办。
|
||||
|
||||
分页:
|
||||
默认每页 20 条。--size 超过 20 时,CLI 会自动进行多次 API 调用
|
||||
并合并结果(自动分页),无需手动翻页。`),
|
||||
Example: ` dws todo task list --page 1 --size 20 --status false`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
@@ -362,6 +374,23 @@ func newTodoTaskGetCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "get",
|
||||
Short: i18n.T("待办详情"),
|
||||
Long: i18n.T(`查看待办任务详情。
|
||||
|
||||
返回字段说明:
|
||||
creatorId / executorIds / participantIds / modifierId
|
||||
待办系统内部人员标识(短数字 ID,如 6380165826),
|
||||
不是通讯录 userId(如 035551044606950179)或 unionid。
|
||||
这些 ID 在待办系统内对同一用户稳定,但无法直接用于通讯录 API 查询。
|
||||
如需获取人员姓名,可参考返回中的 creatorInfo / executorInfos /
|
||||
participantInfos 字段(包含 name 属性)。
|
||||
|
||||
bizTag / source
|
||||
底层待办引擎的实现标识。即使是在钉钉客户端直接创建的普通个人待办,
|
||||
也会返回 "teambition",这是内核实现细节,不代表来自 Teambition 产品。
|
||||
|
||||
tenantId / tenantType
|
||||
待办所属的租户标识,非企业 corpId。tenantType 为 "user" 时
|
||||
tenantId 是用户维度标识;为 "org" 时是组织维度标识。`),
|
||||
Example: ` dws todo task get --task-id <taskId>
|
||||
|
||||
# 查询 taskId: dws todo task list`,
|
||||
@@ -379,7 +408,7 @@ func newTodoTaskGetCommand(runner executor.Runner) *cobra.Command {
|
||||
invocation := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
"todo",
|
||||
"query_todo_detail",
|
||||
"get_todo_detail",
|
||||
params,
|
||||
)
|
||||
invocation.DryRun = commandDryRun(cmd)
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package pat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
const browserPolicyFile = "pat_policy.json"
|
||||
|
||||
type browserPolicyValue struct {
|
||||
OpenBrowser bool `json:"openBrowser"`
|
||||
}
|
||||
|
||||
type BrowserPolicy struct {
|
||||
Default *browserPolicyValue `json:"default,omitempty"`
|
||||
Agents map[string]browserPolicyValue `json:"agents,omitempty"`
|
||||
}
|
||||
|
||||
type BrowserPolicySelection struct {
|
||||
Scope string `json:"scope"`
|
||||
AgentCode string `json:"agentCode,omitempty"`
|
||||
OpenBrowser bool `json:"openBrowser"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
func patPolicyPath(configDir string) string {
|
||||
return filepath.Join(configDir, browserPolicyFile)
|
||||
}
|
||||
|
||||
func patConfigDir() string {
|
||||
if envDir := os.Getenv("DWS_CONFIG_DIR"); envDir != "" {
|
||||
return envDir
|
||||
}
|
||||
if fn := edition.Get().ConfigDir; fn != nil {
|
||||
return fn()
|
||||
}
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ".dws"
|
||||
}
|
||||
return filepath.Join(homeDir, ".dws")
|
||||
}
|
||||
|
||||
func LoadBrowserPolicy(configDir string) (*BrowserPolicy, error) {
|
||||
path := patPolicyPath(configDir)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &BrowserPolicy{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("reading PAT browser policy: %w", err)
|
||||
}
|
||||
|
||||
var policy BrowserPolicy
|
||||
if err := json.Unmarshal(data, &policy); err != nil {
|
||||
return nil, fmt.Errorf("parsing PAT browser policy: %w", err)
|
||||
}
|
||||
if policy.Agents == nil {
|
||||
policy.Agents = map[string]browserPolicyValue{}
|
||||
}
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
func saveBrowserPolicy(configDir string, policy *BrowserPolicy) error {
|
||||
if policy == nil {
|
||||
policy = &BrowserPolicy{}
|
||||
}
|
||||
if policy.Agents == nil {
|
||||
policy.Agents = map[string]browserPolicyValue{}
|
||||
}
|
||||
data, err := json.MarshalIndent(policy, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling PAT browser policy: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := helpers.AtomicWriteJSON(patPolicyPath(configDir), data); err != nil {
|
||||
return fmt.Errorf("writing PAT browser policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ResolveBrowserPolicy(configDir, explicitAgentCode string) (BrowserPolicySelection, error) {
|
||||
agentCode, err := resolveAgentCode(explicitAgentCode, false)
|
||||
if err != nil {
|
||||
return BrowserPolicySelection{}, err
|
||||
}
|
||||
policy, err := LoadBrowserPolicy(configDir)
|
||||
if err != nil {
|
||||
return BrowserPolicySelection{}, err
|
||||
}
|
||||
|
||||
if agentCode != "" {
|
||||
if entry, ok := policy.Agents[agentCode]; ok {
|
||||
return BrowserPolicySelection{
|
||||
Scope: "agent",
|
||||
AgentCode: agentCode,
|
||||
OpenBrowser: entry.OpenBrowser,
|
||||
Source: "agent",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if policy.Default != nil {
|
||||
return BrowserPolicySelection{
|
||||
Scope: "default",
|
||||
OpenBrowser: policy.Default.OpenBrowser,
|
||||
Source: "default",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return BrowserPolicySelection{
|
||||
Scope: "builtin_default",
|
||||
AgentCode: agentCode,
|
||||
OpenBrowser: true,
|
||||
Source: "builtin_default",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func EffectiveOpenBrowser(configDir string) bool {
|
||||
selection, err := ResolveBrowserPolicy(configDir, "")
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return selection.OpenBrowser
|
||||
}
|
||||
|
||||
func resolveBrowserPolicyWriteAgentCode(explicitAgentCode string) (string, error) {
|
||||
agentCode := strings.TrimSpace(explicitAgentCode)
|
||||
if agentCode == "" {
|
||||
return "", nil
|
||||
}
|
||||
if err := validateAgentCode(agentCode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return agentCode, nil
|
||||
}
|
||||
|
||||
func SetBrowserPolicy(configDir, explicitAgentCode string, enabled bool) (BrowserPolicySelection, error) {
|
||||
agentCode, err := resolveBrowserPolicyWriteAgentCode(explicitAgentCode)
|
||||
if err != nil {
|
||||
return BrowserPolicySelection{}, err
|
||||
}
|
||||
|
||||
policy, err := LoadBrowserPolicy(configDir)
|
||||
if err != nil {
|
||||
return BrowserPolicySelection{}, err
|
||||
}
|
||||
if policy.Agents == nil {
|
||||
policy.Agents = map[string]browserPolicyValue{}
|
||||
}
|
||||
|
||||
if agentCode != "" {
|
||||
policy.Agents[agentCode] = browserPolicyValue{OpenBrowser: enabled}
|
||||
if err := saveBrowserPolicy(configDir, policy); err != nil {
|
||||
return BrowserPolicySelection{}, err
|
||||
}
|
||||
return BrowserPolicySelection{
|
||||
Scope: "agent",
|
||||
AgentCode: agentCode,
|
||||
OpenBrowser: enabled,
|
||||
Source: "agent",
|
||||
}, nil
|
||||
}
|
||||
|
||||
policy.Default = &browserPolicyValue{OpenBrowser: enabled}
|
||||
if err := saveBrowserPolicy(configDir, policy); err != nil {
|
||||
return BrowserPolicySelection{}, err
|
||||
}
|
||||
return BrowserPolicySelection{
|
||||
Scope: "default",
|
||||
OpenBrowser: enabled,
|
||||
Source: "default",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newBrowserPolicyCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "browser-policy",
|
||||
Short: "配置 PAT 授权时是否打开浏览器",
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !cmd.Flags().Changed("enabled") {
|
||||
return fmt.Errorf("--enabled is required")
|
||||
}
|
||||
|
||||
enabled, err := cmd.Flags().GetBool("enabled")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
agentCode, err := cmd.Flags().GetString("agentCode")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
selection, err := SetBrowserPolicy(patConfigDir(), agentCode, enabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output.WriteJSON(cmd.OutOrStdout(), selection)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Bool("enabled", false, "PAT 撞墙时是否允许本地打开浏览器")
|
||||
cmd.Flags().String("agentCode", "", "Agent 唯一标识(可选;不填则写入全局默认策略,不从 env DINGTALK_DWS_AGENTCODE 回退)")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package pat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBrowserPolicy_DefaultRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
saved, err := SetBrowserPolicy(configDir, "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
if saved.Scope != "default" {
|
||||
t.Fatalf("saved.Scope = %q, want default", saved.Scope)
|
||||
}
|
||||
if saved.OpenBrowser {
|
||||
t.Fatal("saved.OpenBrowser = true, want false")
|
||||
}
|
||||
|
||||
loaded, err := ResolveBrowserPolicy(configDir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
if loaded.Scope != "default" {
|
||||
t.Fatalf("loaded.Scope = %q, want default", loaded.Scope)
|
||||
}
|
||||
if loaded.OpenBrowser {
|
||||
t.Fatal("loaded.OpenBrowser = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetBrowserPolicy_EmptyAgentCodeIgnoresEnvAndWritesDefault(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "agt-env")
|
||||
configDir := t.TempDir()
|
||||
|
||||
saved, err := SetBrowserPolicy(configDir, "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
if saved.Scope != "default" {
|
||||
t.Fatalf("saved.Scope = %q, want default", saved.Scope)
|
||||
}
|
||||
if saved.AgentCode != "" {
|
||||
t.Fatalf("saved.AgentCode = %q, want empty", saved.AgentCode)
|
||||
}
|
||||
|
||||
policy, err := LoadBrowserPolicy(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBrowserPolicy error = %v", err)
|
||||
}
|
||||
if policy.Default == nil {
|
||||
t.Fatal("policy.Default is nil, want default policy")
|
||||
}
|
||||
if got := len(policy.Agents); got != 0 {
|
||||
t.Fatalf("len(policy.Agents) = %d, want 0", got)
|
||||
}
|
||||
|
||||
loaded, err := ResolveBrowserPolicy(configDir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveBrowserPolicy(default under env) error = %v", err)
|
||||
}
|
||||
if loaded.Scope != "default" {
|
||||
t.Fatalf("loaded.Scope = %q, want default", loaded.Scope)
|
||||
}
|
||||
if loaded.AgentCode != "" {
|
||||
t.Fatalf("loaded.AgentCode = %q, want empty", loaded.AgentCode)
|
||||
}
|
||||
if loaded.OpenBrowser {
|
||||
t.Fatal("loaded.OpenBrowser = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserPolicy_AgentOverridesDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
if _, err := SetBrowserPolicy(configDir, "", true); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
if _, err := SetBrowserPolicy(configDir, "agt-sales", false); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(agent) error = %v", err)
|
||||
}
|
||||
|
||||
agentLoaded, err := ResolveBrowserPolicy(configDir, "agt-sales")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveBrowserPolicy(agent) error = %v", err)
|
||||
}
|
||||
if agentLoaded.Scope != "agent" {
|
||||
t.Fatalf("agentLoaded.Scope = %q, want agent", agentLoaded.Scope)
|
||||
}
|
||||
if agentLoaded.AgentCode != "agt-sales" {
|
||||
t.Fatalf("agentLoaded.AgentCode = %q, want agt-sales", agentLoaded.AgentCode)
|
||||
}
|
||||
if agentLoaded.OpenBrowser {
|
||||
t.Fatal("agentLoaded.OpenBrowser = true, want false")
|
||||
}
|
||||
|
||||
defaultLoaded, err := ResolveBrowserPolicy(configDir, "agt-other")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveBrowserPolicy(default fallback) error = %v", err)
|
||||
}
|
||||
if defaultLoaded.Scope != "default" {
|
||||
t.Fatalf("defaultLoaded.Scope = %q, want default", defaultLoaded.Scope)
|
||||
}
|
||||
if !defaultLoaded.OpenBrowser {
|
||||
t.Fatal("defaultLoaded.OpenBrowser = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBrowserPolicy_EnvFallback(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "agt-env")
|
||||
configDir := t.TempDir()
|
||||
if _, err := SetBrowserPolicy(configDir, "agt-env", false); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(agent) error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := ResolveBrowserPolicy(configDir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveBrowserPolicy(env fallback) error = %v", err)
|
||||
}
|
||||
if loaded.Scope != "agent" {
|
||||
t.Fatalf("loaded.Scope = %q, want agent", loaded.Scope)
|
||||
}
|
||||
if loaded.AgentCode != "agt-env" {
|
||||
t.Fatalf("loaded.AgentCode = %q, want agt-env", loaded.AgentCode)
|
||||
}
|
||||
if loaded.OpenBrowser {
|
||||
t.Fatal("loaded.OpenBrowser = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBrowserPolicy_FallsBackToOpenSourceDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
loaded, err := ResolveBrowserPolicy(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveBrowserPolicy(fallback) error = %v", err)
|
||||
}
|
||||
if loaded.Scope != "builtin_default" {
|
||||
t.Fatalf("loaded.Scope = %q, want builtin_default", loaded.Scope)
|
||||
}
|
||||
if !loaded.OpenBrowser {
|
||||
t.Fatal("loaded.OpenBrowser = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserPolicyCommand_WritesAgentPolicy(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
cmd := newBrowserPolicyCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stdout)
|
||||
cmd.SetArgs([]string{"--enabled=false", "--agentCode", "agt-command"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("browser-policy Execute() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(command output) error = %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
if got, _ := payload["scope"].(string); got != "agent" {
|
||||
t.Fatalf("scope = %q, want agent", got)
|
||||
}
|
||||
if got, _ := payload["agentCode"].(string); got != "agt-command" {
|
||||
t.Fatalf("agentCode = %q, want agt-command", got)
|
||||
}
|
||||
if got, _ := payload["openBrowser"].(bool); got {
|
||||
t.Fatal("openBrowser = true, want false")
|
||||
}
|
||||
|
||||
loaded, err := ResolveBrowserPolicy(configDir, "agt-command")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveBrowserPolicy(agent) error = %v", err)
|
||||
}
|
||||
if loaded.OpenBrowser {
|
||||
t.Fatal("loaded.OpenBrowser = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserPolicyCommand_NoAgentCodeWritesDefaultEvenWhenEnvSet(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
t.Setenv(agentCodeEnv, "agt-env")
|
||||
|
||||
cmd := newBrowserPolicyCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stdout)
|
||||
cmd.SetArgs([]string{"--enabled=false"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("browser-policy Execute() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(command output) error = %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
if got, _ := payload["scope"].(string); got != "default" {
|
||||
t.Fatalf("scope = %q, want default", got)
|
||||
}
|
||||
if _, ok := payload["agentCode"]; ok {
|
||||
t.Fatalf("unexpected agentCode in default policy output: %v", payload["agentCode"])
|
||||
}
|
||||
|
||||
policy, err := LoadBrowserPolicy(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBrowserPolicy error = %v", err)
|
||||
}
|
||||
if policy.Default == nil {
|
||||
t.Fatal("policy.Default is nil, want default policy")
|
||||
}
|
||||
if got := len(policy.Agents); got != 0 {
|
||||
t.Fatalf("len(policy.Agents) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
+309
-21
@@ -16,8 +16,11 @@ package pat
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -26,13 +29,132 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// resolveSessionIDFromEnv returns the effective session id from environment
|
||||
// variables. Resolution order:
|
||||
// 1. DWS_SESSION_ID (primary, stable env name).
|
||||
// 2. REWIND_SESSION_ID (compatibility alias; kept only so hosts that
|
||||
// already inject the legacy trace triple keep working without code
|
||||
// churn).
|
||||
//
|
||||
// When both are set to different non-empty values, DWS_SESSION_ID wins
|
||||
// silently. We deliberately do NOT log either raw session id value or
|
||||
// any derived fingerprint: this resolver is invoked by `dws pat chmod`
|
||||
// session grants, and any stderr / ~/.dws/logs capture of those
|
||||
// identifiers can land verbatim in attached troubleshooting bundles.
|
||||
// Hosts that need to detect a mismatch between the two env vars must do
|
||||
// so on the host side before invoking the CLI.
|
||||
func resolveSessionIDFromEnv() string {
|
||||
if dws := os.Getenv("DWS_SESSION_ID"); dws != "" {
|
||||
return dws
|
||||
}
|
||||
return os.Getenv("REWIND_SESSION_ID")
|
||||
}
|
||||
|
||||
// agentCodeEnv is the canonical (and only) environment variable name
|
||||
// used as a per-shell fallback for the --agentCode flag on `dws pat *`
|
||||
// commands.
|
||||
//
|
||||
// Why: agent hosts typically set their business agent code once when
|
||||
// spawning a long-lived shell / sub-process; requiring `--agentCode` on
|
||||
// every command in that shell forces the host to rewrite every argv.
|
||||
// Exposing DINGTALK_DWS_AGENTCODE lets the host export the code once and
|
||||
// let the CLI resolve it on every pat subcommand. The flag always wins
|
||||
// when both are set so scripted one-offs remain deterministic.
|
||||
//
|
||||
// Namespace note: DWS_AGENTCODE / DINGTALK_AGENTCODE / REWIND_AGENTCODE
|
||||
// are explicitly NOT consumed. The legacy DWS_AGENTCODE alias was
|
||||
// hard-removed once the public integration surface landed on
|
||||
// DINGTALK_DWS_AGENTCODE; hosts must migrate rather than rely on a
|
||||
// silent fallback.
|
||||
const agentCodeEnv = "DINGTALK_DWS_AGENTCODE"
|
||||
|
||||
// agentCodePattern is the validation regex for any --agentCode value
|
||||
// resolved from either the flag or the agent-code env var. It matches
|
||||
// documented agent-code generation schemes (e.g. md5 digests, uuid-like
|
||||
// ids, short host-assigned slugs) while rejecting shell metacharacters
|
||||
// and whitespace that would otherwise flow unescaped into an MCP tool
|
||||
// argument.
|
||||
var agentCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
|
||||
// resolveAgentCodeFromEnv returns the fallback agent code from the
|
||||
// canonical DINGTALK_DWS_AGENTCODE env var. The second return value
|
||||
// reports the env name that was consumed (for error attribution); it
|
||||
// is "" when the env is unset or blank. No legacy aliases are honored.
|
||||
func resolveAgentCodeFromEnv() (string, string) {
|
||||
primary := strings.TrimSpace(os.Getenv(agentCodeEnv))
|
||||
if primary != "" {
|
||||
return primary, agentCodeEnv
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// validateAgentCode rejects agent codes that would be ambiguous or unsafe
|
||||
// once spliced into a shell / MCP argv. Allowed character set is
|
||||
// [A-Za-z0-9_-], length 1..64 — see agentCodePattern above.
|
||||
func validateAgentCode(code string) error {
|
||||
if code == "" {
|
||||
return fmt.Errorf("--agentCode must not be empty")
|
||||
}
|
||||
if !agentCodePattern.MatchString(code) {
|
||||
return fmt.Errorf(
|
||||
"invalid agentCode %q: must match %s (A-Z, a-z, 0-9, _, -; 1..64 chars)",
|
||||
code, agentCodePattern.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveAgentCode implements the canonical two-tier lookup for
|
||||
// --agentCode:
|
||||
//
|
||||
// 1. explicit --agentCode flag value (highest priority; wins over env)
|
||||
// 2. DINGTALK_DWS_AGENTCODE env var (per-shell primary fallback)
|
||||
// 3. empty ("") when required=false; typed error when required=true.
|
||||
//
|
||||
// Any non-empty resolved value is validated via validateAgentCode, so
|
||||
// callers never have to re-validate.
|
||||
func resolveAgentCode(flagVal string, required bool) (string, error) {
|
||||
code := strings.TrimSpace(flagVal)
|
||||
envSource := ""
|
||||
if code == "" {
|
||||
code, envSource = resolveAgentCodeFromEnv()
|
||||
}
|
||||
if code == "" {
|
||||
if required {
|
||||
return "", fmt.Errorf(
|
||||
"flag --agentCode is required (or set env %s)\n hint: dws pat chmod <scope>... --agentCode <id>\n hint: export %s=<id>",
|
||||
agentCodeEnv, agentCodeEnv)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
if err := validateAgentCode(code); err != nil {
|
||||
if envSource != "" {
|
||||
return "", fmt.Errorf("%s env: %w", envSource, err)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
const (
|
||||
// patGrantToolName is the English-first wire name for the PAT grant tool.
|
||||
patGrantToolName = "pat.grant"
|
||||
|
||||
// patGrantToolNameLegacyAlias is retained for server builds that still
|
||||
// expose only the legacy Chinese display name.
|
||||
patGrantToolNameLegacyAlias = "个人授权"
|
||||
)
|
||||
|
||||
var validGrantTypes = map[string]bool{
|
||||
"once": true,
|
||||
"session": true,
|
||||
"permanent": true,
|
||||
}
|
||||
|
||||
func newChmodCommand(caller edition.ToolCaller) *cobra.Command {
|
||||
// newChmodCommand builds a fresh `dws pat chmod` cobra.Command wired to
|
||||
// the supplied ToolCaller. A factory is used (instead of a package-level
|
||||
// var) so multiple RegisterCommands invocations never share mutable flag /
|
||||
// RunE state across concurrent tests.
|
||||
func newChmodCommand(c edition.ToolCaller) *cobra.Command {
|
||||
chmodCmd := &cobra.Command{
|
||||
Use: "chmod <scope>...",
|
||||
Short: "授予指定权限",
|
||||
@@ -50,9 +172,10 @@ grantType 规则:
|
||||
dws pat chmod chat.message:list --grant-type once --agentCode agt-xxxx
|
||||
dws pat chmod aitable.record:read aitable.record:write --agentCode agt-xxxx --grant-type permanent`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
agentCode, _ := cmd.Flags().GetString("agentCode")
|
||||
if agentCode == "" {
|
||||
return fmt.Errorf("flag --agentCode is required\n hint: dws pat chmod <scope>... --agentCode <id>")
|
||||
flagVal, _ := cmd.Flags().GetString("agentCode")
|
||||
agentCode, err := resolveAgentCode(flagVal, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scopes := args
|
||||
grantType, _ := cmd.Flags().GetString("grant-type")
|
||||
@@ -62,14 +185,14 @@ grantType 规则:
|
||||
return fmt.Errorf("invalid --grant-type %q, must be one of: once, session, permanent", grantType)
|
||||
}
|
||||
|
||||
if grantType == "session" && sessionID == "" && os.Getenv("DWS_SESSION_ID") == "" {
|
||||
if grantType == "session" && sessionID == "" && resolveSessionIDFromEnv() == "" {
|
||||
return fmt.Errorf("--session-id is required when --grant-type is session\n hint: dws pat chmod <scope> --agentCode <id> --grant-type session --session-id <id>")
|
||||
}
|
||||
|
||||
if caller != nil && caller.DryRun() {
|
||||
if c != nil && c.DryRun() {
|
||||
bold := color.New(color.FgYellow, color.Bold)
|
||||
bold.Println("[DRY-RUN] Preview only, not executed:")
|
||||
fmt.Printf("%-16s%s\n", "Tool:", "个人授权")
|
||||
fmt.Printf("%-16s%s\n", "Tool:", patGrantToolName)
|
||||
fmt.Printf("%-16s%s\n", "AgentCode:", agentCode)
|
||||
fmt.Printf("%-16s%v\n", "Scope:", scopes)
|
||||
fmt.Printf("%-16s%s\n", "GrantType:", grantType)
|
||||
@@ -79,24 +202,35 @@ grantType 规则:
|
||||
return nil
|
||||
}
|
||||
|
||||
if caller == nil {
|
||||
if c == nil {
|
||||
return fmt.Errorf("internal error: tool runtime not initialized")
|
||||
}
|
||||
|
||||
if sessionID == "" {
|
||||
sessionID = resolveSessionIDFromEnv()
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"agentCode": agentCode,
|
||||
"scope": scopes,
|
||||
"scopes": scopes,
|
||||
"grantType": grantType,
|
||||
}
|
||||
if sessionID == "" {
|
||||
sessionID = os.Getenv("DWS_SESSION_ID")
|
||||
}
|
||||
if sessionID != "" {
|
||||
toolArgs["sessionId"] = sessionID
|
||||
}
|
||||
// Legacy server schema accepted singular "scope"; clone the
|
||||
// canonical argv and rename the key so the two payloads stay
|
||||
// in lock-step on every other field.
|
||||
legacyToolArgs := make(map[string]any, len(toolArgs))
|
||||
for k, v := range toolArgs {
|
||||
if k == "scopes" {
|
||||
legacyToolArgs["scope"] = v
|
||||
continue
|
||||
}
|
||||
legacyToolArgs[k] = v
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := caller.CallTool(ctx, "pat", "个人授权", toolArgs)
|
||||
result, err := callPATToolWithLegacyFallback(ctx, c, "pat", patGrantToolName, patGrantToolNameLegacyAlias, toolArgs, legacyToolArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pat chmod failed: %w", err)
|
||||
}
|
||||
@@ -105,14 +239,172 @@ grantType 规则:
|
||||
},
|
||||
}
|
||||
|
||||
chmodCmd.Flags().String("agentCode", "", "Agent 唯一标识(必填)")
|
||||
_ = chmodCmd.MarkFlagRequired("agentCode")
|
||||
// --agentCode is required, but we deliberately do NOT call
|
||||
// MarkFlagRequired here. The agent code may also come from the
|
||||
// DINGTALK_DWS_AGENTCODE env var; cobra's MarkFlagRequired would
|
||||
// refuse to run before our resolver has a chance to consume the env.
|
||||
chmodCmd.Flags().String("agentCode", "",
|
||||
"Agent 唯一标识(必填;亦可通过 env DINGTALK_DWS_AGENTCODE 注入,flag 优先)")
|
||||
chmodCmd.Flags().String("grant-type", "session", "授权策略: once|session|permanent")
|
||||
chmodCmd.Flags().String("session-id", "", "会话标识(session 模式下必填)")
|
||||
|
||||
return chmodCmd
|
||||
}
|
||||
|
||||
// callPATToolWithLegacyFallback invokes the canonical PAT grant tool first,
|
||||
// then silently retries the legacy Chinese alias when the server has not
|
||||
// registered the canonical tool yet. The retry intentionally emits no stderr
|
||||
// banner because host-owned PAT callers parse stderr as machine JSON.
|
||||
func callPATToolWithLegacyFallback(ctx context.Context, c edition.ToolCaller, productID, toolName, legacyAlias string, toolArgs, legacyArgs map[string]any) (*edition.ToolResult, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("internal error: tool runtime not initialized")
|
||||
}
|
||||
result, err := c.CallTool(ctx, productID, toolName, toolArgs)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if legacyAlias == "" {
|
||||
return nil, err
|
||||
}
|
||||
if !isToolNotRegisteredError(err) && !isLegacyGrantSchemaMismatchError(err, toolArgs, legacyArgs) {
|
||||
return nil, err
|
||||
}
|
||||
return c.CallTool(ctx, productID, legacyAlias, legacyArgs)
|
||||
}
|
||||
|
||||
func isEmptyToolResult(result *edition.ToolResult) bool {
|
||||
if result == nil || len(result.Content) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, block := range result.Content {
|
||||
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isToolNotRegisteredError reports whether err looks like a server-side
|
||||
// tool-not-registered / tool-not-found classification. We match on a few
|
||||
// conservative substrings rather than a structured error type because the
|
||||
// upstream runner surfaces the server message as plain text.
|
||||
func isToolNotRegisteredError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := normalizedPATErrorText(err)
|
||||
needles := []string{
|
||||
"tool_not_found",
|
||||
"mcp_tool_not_found",
|
||||
"tool not found",
|
||||
"tool not registered",
|
||||
"tool not exist",
|
||||
"tool does not exist",
|
||||
"unknown tool",
|
||||
"no such tool",
|
||||
"未找到指定工具",
|
||||
"未找到工具",
|
||||
"工具不存在",
|
||||
}
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(msg, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isLegacyGrantSchemaMismatchError(err error, toolArgs, legacyArgs map[string]any) bool {
|
||||
if err == nil || !hasScopeKeyShapeMismatch(toolArgs, legacyArgs) {
|
||||
return false
|
||||
}
|
||||
if apperrors.IsPATError(err) {
|
||||
return false
|
||||
}
|
||||
msg := normalizedPATErrorText(err)
|
||||
if containsAny(msg,
|
||||
"pat_no_permission",
|
||||
"pat_low_risk_no_permission",
|
||||
"pat_medium_risk_no_permission",
|
||||
"pat_high_risk_no_permission",
|
||||
"pat_scope_auth_required",
|
||||
"agent_code_not_exists",
|
||||
"requiredscopes",
|
||||
"missingscope",
|
||||
"missing_scope",
|
||||
"insufficient_scope",
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if !containsAny(msg, "scope", "scopes") {
|
||||
return false
|
||||
}
|
||||
if !containsAny(msg,
|
||||
"param_error",
|
||||
"参数错误",
|
||||
"parameter",
|
||||
"validation",
|
||||
"required",
|
||||
"missing",
|
||||
"unknown",
|
||||
"unexpected",
|
||||
"invalid",
|
||||
"unmarshal",
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if containsAny(msg,
|
||||
"permission denied",
|
||||
"no permission",
|
||||
"forbidden",
|
||||
"unauthorized",
|
||||
"auth required",
|
||||
"无权限",
|
||||
"未授权",
|
||||
"pat_medium_risk_no_permission",
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasScopeKeyShapeMismatch(toolArgs, legacyArgs map[string]any) bool {
|
||||
if toolArgs == nil || legacyArgs == nil {
|
||||
return false
|
||||
}
|
||||
_, hasCanonicalPlural := toolArgs["scopes"]
|
||||
_, hasCanonicalSingular := toolArgs["scope"]
|
||||
_, hasLegacyPlural := legacyArgs["scopes"]
|
||||
_, hasLegacySingular := legacyArgs["scope"]
|
||||
return hasCanonicalPlural && !hasCanonicalSingular && hasLegacySingular && !hasLegacyPlural
|
||||
}
|
||||
|
||||
func normalizedPATErrorText(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
parts := []string{strings.ToLower(err.Error())}
|
||||
var typed *apperrors.Error
|
||||
if stderrors.As(err, &typed) && typed != nil {
|
||||
parts = append(parts,
|
||||
strings.ToLower(typed.Reason),
|
||||
strings.ToLower(typed.ServerDiag.ServerErrorCode),
|
||||
strings.ToLower(typed.ServerDiag.TechnicalDetail),
|
||||
strings.ToLower(typed.Hint),
|
||||
)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func containsAny(msg string, needles ...string) bool {
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(msg, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleToolResult processes a ToolResult and writes output to stdout.
|
||||
func handleToolResult(result *edition.ToolResult) error {
|
||||
if result == nil {
|
||||
@@ -128,10 +420,6 @@ func handleToolResult(result *edition.ToolResult) error {
|
||||
fmt.Println(c.Text)
|
||||
return nil
|
||||
}
|
||||
data, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal result: %w", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
data, _ := json.Marshal(result)
|
||||
return fmt.Errorf("empty PAT authorization result: %s", string(data))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package pat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// fakeToolCaller captures the toolArgs passed to CallTool so tests can
|
||||
// assert how the two-tier --agentCode / DINGTALK_DWS_AGENTCODE / error
|
||||
// resolver feeds into the outgoing MCP argv.
|
||||
type fakeToolCaller struct {
|
||||
mu sync.Mutex
|
||||
dryRun bool
|
||||
gotTool string
|
||||
gotArgs map[string]any
|
||||
callN int
|
||||
resultOK bool
|
||||
}
|
||||
|
||||
func (f *fakeToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.callN++
|
||||
f.gotTool = toolName
|
||||
// defensive copy — RunE / runApply may mutate the map after return
|
||||
f.gotArgs = make(map[string]any, len(args))
|
||||
for k, v := range args {
|
||||
f.gotArgs[k] = v
|
||||
}
|
||||
// Empty success payload keeps handleToolResult / emitApplyResult happy
|
||||
// without triggering PAT classification in errors.ClassifyMCPResponseText.
|
||||
if f.resultOK {
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"data":{}}`}}}, nil
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"data":{"authRequestId":"req-ok"}}`}}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeToolCaller) Format() string { return "json" }
|
||||
func (f *fakeToolCaller) DryRun() bool { return f.dryRun }
|
||||
|
||||
type recordedToolCall struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type fallbackToolCaller struct {
|
||||
calls []recordedToolCall
|
||||
}
|
||||
|
||||
func (f *fallbackToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for k, v := range args {
|
||||
copied[k] = v
|
||||
}
|
||||
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
|
||||
if len(f.calls) == 1 {
|
||||
return &edition.ToolResult{}, nil
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"data":{"authRequestId":"req-ok"}}`}}}, nil
|
||||
}
|
||||
|
||||
func (f *fallbackToolCaller) Format() string { return "json" }
|
||||
func (f *fallbackToolCaller) DryRun() bool { return false }
|
||||
|
||||
type fallbackErrorToolCaller struct {
|
||||
calls []recordedToolCall
|
||||
}
|
||||
|
||||
func (f *fallbackErrorToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for k, v := range args {
|
||||
copied[k] = v
|
||||
}
|
||||
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
|
||||
if len(f.calls) == 1 {
|
||||
return nil, errors.New("pat chmod failed: business error: PARAM_ERROR - 未找到指定工具")
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"data":{"authRequestId":"req-ok"}}`}}}, nil
|
||||
}
|
||||
|
||||
func (f *fallbackErrorToolCaller) Format() string { return "json" }
|
||||
func (f *fallbackErrorToolCaller) DryRun() bool { return false }
|
||||
|
||||
type fallbackSchemaMismatchToolCaller struct {
|
||||
calls []recordedToolCall
|
||||
}
|
||||
|
||||
func (f *fallbackSchemaMismatchToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for k, v := range args {
|
||||
copied[k] = v
|
||||
}
|
||||
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
|
||||
if len(f.calls) == 1 {
|
||||
return nil, apperrors.NewAPI("business error: success=false",
|
||||
apperrors.WithReason("business_error"),
|
||||
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
|
||||
ServerErrorCode: "PARAM_ERROR",
|
||||
TechnicalDetail: `input schema validation failed: unknown field "scopes"; missing required field "scope"`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"data":{"authRequestId":"req-ok"}}`}}}, nil
|
||||
}
|
||||
|
||||
func (f *fallbackSchemaMismatchToolCaller) Format() string { return "json" }
|
||||
func (f *fallbackSchemaMismatchToolCaller) DryRun() bool { return false }
|
||||
|
||||
type fallbackPermissionDeniedToolCaller struct {
|
||||
calls []recordedToolCall
|
||||
}
|
||||
|
||||
func (f *fallbackPermissionDeniedToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for k, v := range args {
|
||||
copied[k] = v
|
||||
}
|
||||
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
|
||||
return nil, apperrors.NewAPI("business error: success=false",
|
||||
apperrors.WithReason("business_error"),
|
||||
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
|
||||
ServerErrorCode: "PAT_MEDIUM_RISK_NO_PERMISSION",
|
||||
TechnicalDetail: "permission denied for scope chat.message:send",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (f *fallbackPermissionDeniedToolCaller) Format() string { return "json" }
|
||||
func (f *fallbackPermissionDeniedToolCaller) DryRun() bool { return false }
|
||||
|
||||
type fallbackPATErrorToolCaller struct {
|
||||
calls []recordedToolCall
|
||||
}
|
||||
|
||||
func (f *fallbackPATErrorToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for k, v := range args {
|
||||
copied[k] = v
|
||||
}
|
||||
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
|
||||
return nil, &apperrors.PATError{RawJSON: `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED","data":{"missingScope":"mail:send"}}`}
|
||||
}
|
||||
|
||||
func (f *fallbackPATErrorToolCaller) Format() string { return "json" }
|
||||
func (f *fallbackPATErrorToolCaller) DryRun() bool { return false }
|
||||
|
||||
type fallbackPATContractErrorToolCaller struct {
|
||||
calls []recordedToolCall
|
||||
}
|
||||
|
||||
func (f *fallbackPATContractErrorToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for k, v := range args {
|
||||
copied[k] = v
|
||||
}
|
||||
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
|
||||
return nil, apperrors.NewAPI("business error: success=false",
|
||||
apperrors.WithReason("business_error"),
|
||||
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
|
||||
ServerErrorCode: "PAT_SCOPE_AUTH_REQUIRED",
|
||||
TechnicalDetail: `missingScope mail:send`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (f *fallbackPATContractErrorToolCaller) Format() string { return "json" }
|
||||
func (f *fallbackPATContractErrorToolCaller) DryRun() bool { return false }
|
||||
|
||||
func stringSliceArgEqual(got any, want []string) bool {
|
||||
gotSlice, ok := got.([]string)
|
||||
if !ok || len(gotSlice) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range want {
|
||||
if gotSlice[i] != want[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// buildChmod returns a freshly constructed chmod cobra.Command wired to
|
||||
// fake. Using the factory (instead of a package-level var) keeps every
|
||||
// subtest hermetic and matches the upstream shared-state fix in PR #129.
|
||||
func buildChmod(t *testing.T, fake *fakeToolCaller) *cobra.Command {
|
||||
t.Helper()
|
||||
return newChmodCommand(fake)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T1 · Agent-code env fallback tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestChmod_agentCode_env_fallback verifies that when --agentCode is
|
||||
// omitted but DINGTALK_DWS_AGENTCODE is exported, the resolver picks
|
||||
// the env value up and forwards it verbatim in the MCP argv.
|
||||
func TestChmod_agentCode_env_fallback(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
cmd := buildChmod(t, fake)
|
||||
|
||||
// grant-type=once → no session-id needed; keeps the test hermetic.
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v (must not report flag missing)", err)
|
||||
}
|
||||
|
||||
if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("agentCode in argv = %v, want %q (env fallback)", got, "qoderwork")
|
||||
}
|
||||
if got := fake.gotArgs["scopes"]; !stringSliceArgEqual(got, []string{"aitable.record:read"}) {
|
||||
t.Fatalf("scopes in argv = %#v, want %#v", got, []string{"aitable.record:read"})
|
||||
}
|
||||
if _, ok := fake.gotArgs["scope"]; ok {
|
||||
t.Fatalf("unexpected legacy singular scope arg in argv: %#v", fake.gotArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallPATToolWithLegacyFallback_emptyCanonicalResultDoesNotRetryLegacyAlias(t *testing.T) {
|
||||
fake := &fallbackToolCaller{}
|
||||
canonicalArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scopes": []string{"aitable.record:read"},
|
||||
"grantType": "permanent",
|
||||
}
|
||||
legacyArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scope": []string{"aitable.record:read"},
|
||||
"grantType": "permanent",
|
||||
}
|
||||
|
||||
result, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("callPATToolWithLegacyFallback error = %v", err)
|
||||
}
|
||||
if !isEmptyToolResult(result) {
|
||||
t.Fatalf("expected original empty canonical result, got %#v", result)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("CallTool call count = %d, want 1", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0].tool != patGrantToolName {
|
||||
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patGrantToolName)
|
||||
}
|
||||
if _, ok := fake.calls[0].args["scopes"]; !ok {
|
||||
t.Fatalf("canonical args missing scopes: %#v", fake.calls[0].args)
|
||||
}
|
||||
if _, ok := fake.calls[0].args["scope"]; ok {
|
||||
t.Fatalf("canonical args should not use legacy scope: %#v", fake.calls[0].args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_emptyCanonicalResultReturnsError(t *testing.T) {
|
||||
fake := &fallbackToolCaller{}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("agentCode", "qoderwork")
|
||||
_ = cmd.Flags().Set("grant-type", "permanent")
|
||||
|
||||
err := cmd.RunE(cmd, []string{"aitable.record:read"})
|
||||
if err == nil {
|
||||
t.Fatal("chmod RunE error = nil, want empty PAT authorization result")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty PAT authorization result") {
|
||||
t.Fatalf("chmod RunE error = %q, want empty PAT authorization result", err.Error())
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("CallTool call count = %d, want 1", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0].tool != patGrantToolName {
|
||||
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patGrantToolName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallPATToolWithLegacyFallback_toolNotFoundRetriesLegacyAlias(t *testing.T) {
|
||||
fake := &fallbackErrorToolCaller{}
|
||||
canonicalArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scopes": []string{"aitable.record:read"},
|
||||
"grantType": "permanent",
|
||||
}
|
||||
legacyArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scope": []string{"aitable.record:read"},
|
||||
"grantType": "permanent",
|
||||
}
|
||||
|
||||
result, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("callPATToolWithLegacyFallback error = %v", err)
|
||||
}
|
||||
if isEmptyToolResult(result) {
|
||||
t.Fatalf("fallback result is empty: %#v", result)
|
||||
}
|
||||
if len(fake.calls) != 2 {
|
||||
t.Fatalf("CallTool call count = %d, want 2", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0].tool != patGrantToolName {
|
||||
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patGrantToolName)
|
||||
}
|
||||
if fake.calls[1].tool != patGrantToolNameLegacyAlias {
|
||||
t.Fatalf("fallback tool = %q, want %q", fake.calls[1].tool, patGrantToolNameLegacyAlias)
|
||||
}
|
||||
if _, ok := fake.calls[1].args["scope"]; !ok {
|
||||
t.Fatalf("legacy args missing scope: %#v", fake.calls[1].args)
|
||||
}
|
||||
if _, ok := fake.calls[1].args["scopes"]; ok {
|
||||
t.Fatalf("legacy args should not use canonical scopes: %#v", fake.calls[1].args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallPATToolWithLegacyFallback_schemaMismatchRetriesLegacyAlias(t *testing.T) {
|
||||
fake := &fallbackSchemaMismatchToolCaller{}
|
||||
canonicalArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scopes": []string{"aitable.record:read"},
|
||||
"grantType": "permanent",
|
||||
}
|
||||
legacyArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scope": []string{"aitable.record:read"},
|
||||
"grantType": "permanent",
|
||||
}
|
||||
|
||||
result, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("callPATToolWithLegacyFallback error = %v", err)
|
||||
}
|
||||
if isEmptyToolResult(result) {
|
||||
t.Fatalf("fallback result is empty: %#v", result)
|
||||
}
|
||||
if len(fake.calls) != 2 {
|
||||
t.Fatalf("CallTool call count = %d, want 2", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0].tool != patGrantToolName {
|
||||
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patGrantToolName)
|
||||
}
|
||||
if fake.calls[1].tool != patGrantToolNameLegacyAlias {
|
||||
t.Fatalf("fallback tool = %q, want %q", fake.calls[1].tool, patGrantToolNameLegacyAlias)
|
||||
}
|
||||
if _, ok := fake.calls[1].args["scope"]; !ok {
|
||||
t.Fatalf("legacy args missing scope: %#v", fake.calls[1].args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallPATToolWithLegacyFallback_permissionDeniedDoesNotRetryLegacyAlias(t *testing.T) {
|
||||
fake := &fallbackPermissionDeniedToolCaller{}
|
||||
canonicalArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scopes": []string{"chat.message:send"},
|
||||
"grantType": "once",
|
||||
}
|
||||
legacyArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scope": []string{"chat.message:send"},
|
||||
"grantType": "once",
|
||||
}
|
||||
|
||||
_, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
|
||||
if err == nil {
|
||||
t.Fatal("callPATToolWithLegacyFallback error = nil, want original permission denial")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.ServerDiag.ServerErrorCode != "PAT_MEDIUM_RISK_NO_PERMISSION" {
|
||||
t.Fatalf("ServerErrorCode = %q, want PAT_MEDIUM_RISK_NO_PERMISSION", typed.ServerDiag.ServerErrorCode)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("CallTool call count = %d, want 1", len(fake.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallPATToolWithLegacyFallback_patErrorDoesNotRetryLegacyAlias(t *testing.T) {
|
||||
fake := &fallbackPATErrorToolCaller{}
|
||||
canonicalArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scopes": []string{"mail:send"},
|
||||
"grantType": "once",
|
||||
}
|
||||
legacyArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scope": []string{"mail:send"},
|
||||
"grantType": "once",
|
||||
}
|
||||
|
||||
_, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
|
||||
if err == nil {
|
||||
t.Fatal("callPATToolWithLegacyFallback error = nil, want PATError")
|
||||
}
|
||||
if !apperrors.IsPATError(err) {
|
||||
t.Fatalf("expected PATError, got %T", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("CallTool call count = %d, want 1", len(fake.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallPATToolWithLegacyFallback_patContractErrorDoesNotRetryLegacyAlias(t *testing.T) {
|
||||
fake := &fallbackPATContractErrorToolCaller{}
|
||||
canonicalArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scopes": []string{"mail:send"},
|
||||
"grantType": "once",
|
||||
}
|
||||
legacyArgs := map[string]any{
|
||||
"agentCode": "qoderwork",
|
||||
"scope": []string{"mail:send"},
|
||||
"grantType": "once",
|
||||
}
|
||||
|
||||
_, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
|
||||
if err == nil {
|
||||
t.Fatal("callPATToolWithLegacyFallback error = nil, want original PAT contract error")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.ServerDiag.ServerErrorCode != "PAT_SCOPE_AUTH_REQUIRED" {
|
||||
t.Fatalf("ServerErrorCode = %q, want PAT_SCOPE_AUTH_REQUIRED", typed.ServerDiag.ServerErrorCode)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("CallTool call count = %d, want 1", len(fake.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsToolNotRegisteredError_ChineseGatewayMessage(t *testing.T) {
|
||||
err := errors.New("pat chmod failed: business error: PARAM_ERROR - 未找到指定工具")
|
||||
if !isToolNotRegisteredError(err) {
|
||||
t.Fatalf("isToolNotRegisteredError(%q) = false, want true", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsToolNotRegisteredError_ChineseGatewayDiagnostics(t *testing.T) {
|
||||
err := apperrors.NewAPI("business error: success=false",
|
||||
apperrors.WithReason("business_error"),
|
||||
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
|
||||
ServerErrorCode: "PARAM_ERROR",
|
||||
TechnicalDetail: "Tool metadata API error: PARAM_ERROR - 未找到指定工具",
|
||||
}),
|
||||
)
|
||||
if !isToolNotRegisteredError(err) {
|
||||
t.Fatalf("isToolNotRegisteredError(%q) = false, want true", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleToolResult_emptyResultReturnsError(t *testing.T) {
|
||||
err := handleToolResult(&edition.ToolResult{})
|
||||
if err == nil {
|
||||
t.Fatal("handleToolResult error = nil, want empty PAT authorization result error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty PAT authorization result") {
|
||||
t.Fatalf("handleToolResult error = %q, want empty PAT authorization result", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestChmod_agentCode_env_invalid verifies that a malformed
|
||||
// DINGTALK_DWS_AGENTCODE value (whitespace, shell metacharacters) is
|
||||
// rejected by the regex gate in validateAgentCode before any MCP call
|
||||
// is attempted.
|
||||
func TestChmod_agentCode_env_invalid(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "bad value with space!")
|
||||
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
cmd := buildChmod(t, fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
|
||||
err := cmd.RunE(cmd, []string{"aitable.record:read"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected validateAgentCode error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid agentCode") {
|
||||
t.Fatalf("error = %q, want to mention 'invalid agentCode'", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), agentCodeEnv) {
|
||||
t.Fatalf("error = %q, want to attribute to %s env", err.Error(), agentCodeEnv)
|
||||
}
|
||||
if fake.callN != 0 {
|
||||
t.Fatalf("CallTool was invoked %d times; validator must short-circuit before MCP", fake.callN)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChmod_agentCode_flag_wins_over_env verifies the Priority-1 contract
|
||||
// of resolveAgentCode: when both the flag and the env are set, the flag
|
||||
// wins and env is silently ignored (no warning needed because the flag is
|
||||
// the explicit, scripted intent).
|
||||
func TestChmod_agentCode_flag_wins_over_env(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "envval")
|
||||
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
cmd := buildChmod(t, fake)
|
||||
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("agentCode", "flagval")
|
||||
|
||||
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
if got := fake.gotArgs["agentCode"]; got != "flagval" {
|
||||
t.Fatalf("agentCode in argv = %v, want %q (flag must win over env)", got, "flagval")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChmod_agentCode_legacy_env_not_recognized is a reverse-guard: after
|
||||
// the SSOT hard-removal of the DWS_AGENTCODE alias, exporting only the
|
||||
// legacy env MUST NOT satisfy the --agentCode requirement. The command
|
||||
// is expected to fail with an error that explicitly names the canonical
|
||||
// DINGTALK_DWS_AGENTCODE env, and MUST NOT mention DWS_AGENTCODE as a
|
||||
// usable fallback. No MCP call is permitted.
|
||||
func TestChmod_agentCode_legacy_env_not_recognized(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "")
|
||||
t.Setenv("DWS_AGENTCODE", "legacyval")
|
||||
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
cmd := buildChmod(t, fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
|
||||
err := cmd.RunE(cmd, []string{"aitable.record:read"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected hard error when only legacy DWS_AGENTCODE is set, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "DINGTALK_DWS_AGENTCODE") {
|
||||
t.Fatalf("error = %q, want to name canonical DINGTALK_DWS_AGENTCODE env", err.Error())
|
||||
}
|
||||
// Defensive: the canonical env naturally contains the substring
|
||||
// "DWS_AGENTCODE" as part of "DINGTALK_DWS_AGENTCODE"; the above
|
||||
// assertion plus the absence check below precisely guard against
|
||||
// advertising the legacy alias as usable.
|
||||
hint := strings.ReplaceAll(err.Error(), "DINGTALK_DWS_AGENTCODE", "")
|
||||
if strings.Contains(hint, "DWS_AGENTCODE") {
|
||||
t.Fatalf("error = %q must not advertise DWS_AGENTCODE as usable", err.Error())
|
||||
}
|
||||
if fake.callN != 0 {
|
||||
t.Fatalf("CallTool was invoked %d times; legacy env must not satisfy --agentCode", fake.callN)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateAgentCode / resolveAgentCodeFromEnv unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestValidateAgentCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in string
|
||||
wantErr bool
|
||||
}{
|
||||
{"qoderwork", false},
|
||||
{"agt-abc123", false},
|
||||
{"Agt_Xyz-09", false},
|
||||
{"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", false}, // 64 chars
|
||||
{"", true},
|
||||
{"bad value", true},
|
||||
{"bad!chars", true},
|
||||
{"中文不行", true},
|
||||
{"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789X", true}, // 65
|
||||
}
|
||||
for _, tc := range cases {
|
||||
err := validateAgentCode(tc.in)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("validateAgentCode(%q) err=%v, wantErr=%v", tc.in, err, tc.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentCodeFromEnv(t *testing.T) {
|
||||
// Not parallel: mutates process env.
|
||||
|
||||
// DINGTALK_DWS_AGENTCODE is honoured and trimmed.
|
||||
t.Setenv(agentCodeEnv, " qoderwork ")
|
||||
if code, src := resolveAgentCodeFromEnv(); code != "qoderwork" || src != agentCodeEnv {
|
||||
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want (%q, %q)",
|
||||
code, src, "qoderwork", agentCodeEnv)
|
||||
}
|
||||
|
||||
// Empty primary → ("", "").
|
||||
t.Setenv(agentCodeEnv, "")
|
||||
if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" {
|
||||
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty", code, src)
|
||||
}
|
||||
|
||||
// Reverse-guard: legacy DWS_AGENTCODE MUST NOT be picked up when the
|
||||
// canonical env is unset — it was hard-removed as a legacy alias.
|
||||
t.Setenv(agentCodeEnv, "")
|
||||
t.Setenv("DWS_AGENTCODE", "legacy")
|
||||
if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" {
|
||||
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty — legacy DWS_AGENTCODE must be ignored",
|
||||
code, src)
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -30,10 +30,37 @@ func RegisterCommands(root *cobra.Command, c edition.ToolCaller) {
|
||||
Long: `管理行为授权(PAT)。
|
||||
|
||||
命令结构:
|
||||
dws pat chmod <scope>... 授予指定权限`,
|
||||
dws pat chmod <scope>... 授予指定权限
|
||||
dws pat browser-policy 配置 PAT 浏览器打开策略
|
||||
|
||||
能力说明:
|
||||
--format 只控制 PAT 撞墙时的输出形态;当 --format json 时,
|
||||
CLI 只返回结构化 JSON,不混入非结构化文本。
|
||||
浏览器是否打开由本地 PAT 策略单独决定,与 json / non-json 独立。
|
||||
生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。
|
||||
写入 agent 策略需显式传 --agentCode;不传则写入全局默认策略。
|
||||
|
||||
Host-owned PAT 开关:
|
||||
当且仅当环境变量 DINGTALK_DWS_AGENTCODE 非空时,CLI 命中 PAT
|
||||
固定以 stderr JSON + exit=4 的 host-owned 形式返回,
|
||||
由宿主处理全部 UI / 交互 / 回调节奏 / 重试逻辑,
|
||||
CLI 侧不再拉起任何本地浏览器 / 轮询。
|
||||
|
||||
服务端路由标签 claw-type(开源构建硬编码):
|
||||
开源构建在所有出站 MCP 请求上恒定注入 claw-type: openClaw,
|
||||
与 DINGTALK_AGENT / 宿主环境解耦,与历史 main 行为一致。
|
||||
hostControl.clawType 也会回填该值,便于宿主侧审计/路由。
|
||||
|
||||
DINGTALK_AGENT(可选,仅供 x-dingtalk-agent 使用):
|
||||
如设置,将原样注入 HTTP 请求头 x-dingtalk-agent,
|
||||
便于上游按业务 Agent 名称区分流量。
|
||||
它不参与 claw-type 派生,也不参与 host-owned PAT 判定。
|
||||
|
||||
DWS_CHANNEL 只用于上游 channelCode。`,
|
||||
RunE: cmdutil.GroupRunE,
|
||||
}
|
||||
|
||||
patCmd.AddCommand(newChmodCommand(c))
|
||||
patCmd.AddCommand(newBrowserPolicyCommand())
|
||||
root.AddCommand(patCmd)
|
||||
}
|
||||
|
||||
@@ -572,16 +572,3 @@ func TestSyncSkills(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr))
|
||||
}
|
||||
|
||||
func containsSubstring(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -19,7 +19,15 @@ const (
|
||||
)
|
||||
|
||||
// knownSkillDirs lists all known Agent skill directories (relative to $HOME).
|
||||
// Kept in sync with build/npm/install.js AGENT_DIRS.
|
||||
// Kept in sync with:
|
||||
// - build/npm/install.js AGENT_DIRS
|
||||
// - scripts/install.sh for-in list
|
||||
// - scripts/install.ps1 $AgentDirs
|
||||
// - scripts/install-skills.sh for-in list
|
||||
// - build/homebrew.rb.tmpl targets
|
||||
// - test/scripts/package_script_test.go expectedPackagedSkillTargets
|
||||
// - scripts/release/verify-package-managers.sh HOME_AGENT_PARENTS / HOME_SKILL_TARGETS
|
||||
//
|
||||
// The first entry (.agents/skills) is always updated; subsequent entries are
|
||||
// only updated when their parent directory already exists.
|
||||
var knownSkillDirs = []string{
|
||||
@@ -36,6 +44,7 @@ var knownSkillDirs = []string{
|
||||
".kiro/skills",
|
||||
".trae/skills",
|
||||
".openclaw/skills",
|
||||
".hermes/skills",
|
||||
}
|
||||
|
||||
// skillDirBlacklist contains parent directories whose skills are managed by
|
||||
|
||||
@@ -52,9 +52,19 @@ import (
|
||||
// leaf leaf (hc priority ≤ dyn) dynamic wins (no-op)
|
||||
// leaf leaf (hc priority > dyn) hardcoded replaces dynamic
|
||||
// group group recurse
|
||||
// leaf group dynamic wins, warn
|
||||
// leaf group (hc priority > dyn) hardcoded group replaces dyn leaf
|
||||
// leaf group (hc priority ≤ dyn) dynamic wins, warn
|
||||
// group leaf dynamic wins, warn
|
||||
//
|
||||
// The "leaf vs group" priority promotion path (added for issue #164) covers
|
||||
// the case where the envelope exposes a single tool at a CLI path but the
|
||||
// hardcoded helper restructures that path into a group of richer subcommands
|
||||
// (e.g. `chat group members` published as a leaf for `get_group_members`,
|
||||
// but the helper provides `list / add / remove / add-bot` siblings). Without
|
||||
// this path the helper subtree is silently dropped on every release, which
|
||||
// is exactly the regression the OverridePriority annotation exists to prevent
|
||||
// for leaf-vs-leaf — extending it to leaf-vs-group keeps the contract honest.
|
||||
//
|
||||
// MergeHardcodedLeaves mutates dynamicRoot in place and returns it so callers
|
||||
// can chain. hardcodedRoot is treated as a donor: grafted children are
|
||||
// detached from it so their cobra parent pointer points at the new parent.
|
||||
@@ -81,6 +91,13 @@ func MergeHardcodedLeaves(dynamicRoot, hardcodedRoot *cobra.Command) *cobra.Comm
|
||||
// else: envelope is authority; hardcoded leaf is ignored.
|
||||
case !IsLeafCmd(hc) && !IsLeafCmd(dyn):
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
case IsLeafCmd(dyn) && !IsLeafCmd(hc) && OverridePriority(hc) > OverridePriority(dyn):
|
||||
// Helper restructures a dynamic leaf into a richer subcommand
|
||||
// group; honour the explicit OverridePriority opt-in just like
|
||||
// the leaf-vs-leaf case.
|
||||
hardcodedRoot.RemoveCommand(hc)
|
||||
dynamicRoot.RemoveCommand(dyn)
|
||||
dynamicRoot.AddCommand(hc)
|
||||
default:
|
||||
slog.Warn("overlay: shape mismatch, keeping dynamic",
|
||||
"name", hc.Name(),
|
||||
|
||||
@@ -182,6 +182,66 @@ func TestMergeHardcodedLeaves_ShapeMismatch_KeepsDynamic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeHardcodedLeaves_HigherPriorityHardcodedGroupOverridesDynamicLeaf
|
||||
// covers the leaf↔group shape-mismatch promotion path added for issue #164:
|
||||
// when the envelope publishes a single tool at a CLI path (leaf) but the
|
||||
// helper restructures it into a richer subcommand group, the helper group
|
||||
// must win when it carries OverridePriority strictly higher than the
|
||||
// dynamic leaf — otherwise the helper subtree (e.g. `chat group members
|
||||
// list / add / remove / add-bot`) is silently dropped and the regression
|
||||
// the priority annotation is meant to prevent re-emerges in shape-mismatch
|
||||
// form.
|
||||
func TestMergeHardcodedLeaves_HigherPriorityHardcodedGroupOverridesDynamicLeaf(t *testing.T) {
|
||||
t.Parallel()
|
||||
dynLeaf := newLeaf("shared", "dynamic")
|
||||
dyn := newGroup("root", dynLeaf)
|
||||
hcGroup := newGroup("shared",
|
||||
newLeaf("list", "hardcoded"),
|
||||
newLeaf("add", "hardcoded"),
|
||||
)
|
||||
SetOverridePriority(hcGroup, 100)
|
||||
hc := newGroup("root", hcGroup)
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
got := findChildByName(dyn, "shared")
|
||||
if got == nil {
|
||||
t.Fatal("expected `shared` on dyn after merge")
|
||||
}
|
||||
if got != hcGroup {
|
||||
t.Fatal("expected hardcoded group to replace dynamic leaf")
|
||||
}
|
||||
if findChildByName(got, "list") == nil {
|
||||
t.Fatal("expected hardcoded subtree leaf `list` to be reachable")
|
||||
}
|
||||
if findChildByName(got, "add") == nil {
|
||||
t.Fatal("expected hardcoded subtree leaf `add` to be reachable")
|
||||
}
|
||||
if findChildByName(hc, "shared") != nil {
|
||||
t.Fatal("expected hardcoded `shared` to be detached from hc after replacement")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeHardcodedLeaves_EqualPriorityShapeMismatchKeepsDynamic guards the
|
||||
// boundary: only a strictly-higher priority promotes the helper group.
|
||||
// Equal priority must still keep the envelope as authority (warn case).
|
||||
func TestMergeHardcodedLeaves_EqualPriorityShapeMismatchKeepsDynamic(t *testing.T) {
|
||||
t.Parallel()
|
||||
dynLeaf := newLeaf("shared", "dynamic")
|
||||
SetOverridePriority(dynLeaf, 100)
|
||||
dyn := newGroup("root", dynLeaf)
|
||||
hcGroup := newGroup("shared", newLeaf("list", "hardcoded"))
|
||||
SetOverridePriority(hcGroup, 100)
|
||||
hc := newGroup("root", hcGroup)
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
got := findChildByName(dyn, "shared")
|
||||
if got != dynLeaf {
|
||||
t.Fatalf("equal priorities + shape mismatch must keep dynamic leaf; got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLeafCmd(t *testing.T) {
|
||||
t.Parallel()
|
||||
leaf := newLeaf("x", "")
|
||||
|
||||
@@ -74,6 +74,23 @@ const (
|
||||
DefaultPartition = "default/default"
|
||||
)
|
||||
|
||||
// EditionPartition returns the cache partition for a given edition name.
|
||||
// The open-source core (name == "" or "open") uses DefaultPartition; every
|
||||
// other edition gets its own namespace to prevent cross-edition data
|
||||
// leakage in the disk cache.
|
||||
//
|
||||
// This helper takes the edition name as a parameter instead of calling
|
||||
// edition.Get() so that pkg/config remains a leaf dependency — importable
|
||||
// from internal/cli, internal/app, internal/cache, etc. without risking
|
||||
// import cycles.
|
||||
func EditionPartition(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || name == "open" {
|
||||
return DefaultPartition
|
||||
}
|
||||
return name + "/default"
|
||||
}
|
||||
|
||||
// ── Auth flow timeouts ──────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
|
||||
@@ -74,6 +74,28 @@ func TestDefaultPartition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditionPartition(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"empty maps to default", "", DefaultPartition},
|
||||
{"open maps to default", "open", DefaultPartition},
|
||||
{"whitespace is trimmed", " wukong ", "wukong/default"},
|
||||
{"wukong overlay", "wukong", "wukong/default"},
|
||||
{"arbitrary edition", "internal-lab", "internal-lab/default"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := EditionPartition(tc.input); got != tc.want {
|
||||
t.Fatalf("EditionPartition(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualTokenExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
if ManualTokenExpiry <= 0 {
|
||||
|
||||
+14
-3
@@ -13,9 +13,20 @@
|
||||
|
||||
package edition
|
||||
|
||||
// DefaultOSSClawType is the wire value for request header claw-type in
|
||||
// the open-source build. It is intentionally hard-wired — the open-source
|
||||
// CLI does NOT derive claw-type from DINGTALK_AGENT or any other caller
|
||||
// input, so third-party hosts get a predictable header regardless of
|
||||
// their environment.
|
||||
const DefaultOSSClawType = "openClaw"
|
||||
|
||||
// defaultHooks returns the open-source edition defaults.
|
||||
// All function hooks are nil, which the internal code interprets as
|
||||
// "use standard open-source behaviour".
|
||||
//
|
||||
// MergeHeaders is the only hook that ships with behaviour: it pins the
|
||||
// `claw-type` request header to DefaultOSSClawType so every open-source
|
||||
// MCP request carries the same stable routing tag. All other fields are
|
||||
// nil — the internal code interprets nil as "use standard open-source
|
||||
// behaviour".
|
||||
func defaultHooks() *Hooks {
|
||||
return &Hooks{
|
||||
Name: "open",
|
||||
@@ -23,7 +34,7 @@ func defaultHooks() *Hooks {
|
||||
if base == nil {
|
||||
base = make(map[string]string)
|
||||
}
|
||||
base["claw-type"] = "openClaw"
|
||||
base["claw-type"] = DefaultOSSClawType
|
||||
return base
|
||||
},
|
||||
}
|
||||
|
||||
@@ -116,7 +116,8 @@ install_skills_to_root() {
|
||||
".amp/skills" \
|
||||
".kiro/skills" \
|
||||
".trae/skills" \
|
||||
".openclaw/skills"
|
||||
".openclaw/skills" \
|
||||
".hermes/skills"
|
||||
do
|
||||
base_dir="$root/$agent_dir"
|
||||
parent_gate="$(dirname "$base_dir")"
|
||||
|
||||
+2
-1
@@ -44,7 +44,8 @@ $AgentDirs = @(
|
||||
".amp\skills",
|
||||
".kiro\skills",
|
||||
".trae\skills",
|
||||
".openclaw\skills"
|
||||
".openclaw\skills",
|
||||
".hermes\skills"
|
||||
)
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
+2
-1
@@ -201,7 +201,8 @@ install_skills_to_homes() {
|
||||
".amp/skills" \
|
||||
".kiro/skills" \
|
||||
".trae/skills" \
|
||||
".openclaw/skills"
|
||||
".openclaw/skills" \
|
||||
".hermes/skills"
|
||||
do
|
||||
base_dir="$root/$agent_dir"
|
||||
parent_gate="$(dirname "$base_dir")"
|
||||
|
||||
@@ -37,6 +37,7 @@ HOME_AGENT_PARENTS="
|
||||
.kiro
|
||||
.trae
|
||||
.openclaw
|
||||
.hermes
|
||||
"
|
||||
HOME_SKILL_TARGETS="
|
||||
.agents/skills/dws
|
||||
@@ -52,6 +53,7 @@ HOME_SKILL_TARGETS="
|
||||
.kiro/skills/dws
|
||||
.trae/skills/dws
|
||||
.openclaw/skills/dws
|
||||
.hermes/skills/dws
|
||||
"
|
||||
cleanup() {
|
||||
if command -v brew >/dev/null 2>&1; then
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dws
|
||||
description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要时使用。
|
||||
description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记/邮箱等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件时使用。
|
||||
cli_version: ">=1.0.15"
|
||||
---
|
||||
|
||||
@@ -36,6 +36,7 @@ cli_version: ">=1.0.15"
|
||||
| `minutes` | AI听记:听记列表/摘要/关键词/转写/待办/思维导图/发言人/热词/上传 | [minutes.md](./references/products/minutes.md) |
|
||||
| `oa` | OA审批:待办/我发起的/表单模板/详情/审批流水/同意/拒绝/撤销 | [oa.md](./references/products/oa.md) |
|
||||
| `report` | 日志:按模版创建/收件箱/已发送/模版查看/详情/已读统计 | [report.md](./references/products/report.md) |
|
||||
| `mail` | 邮箱:邮箱地址查询/邮件搜索(KQL)/邮件详情/发送邮件 | [mail.md](./references/products/mail.md) |
|
||||
| `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
|
||||
|
||||
## 意图判断决策树
|
||||
@@ -50,6 +51,7 @@ cli_version: ">=1.0.15"
|
||||
用户提到"钉钉文档/云文档/知识库/读写文档/块级编辑/文档评论/文档复制移动" → `doc`
|
||||
用户提到"云盘/文件存储/文件上传下载/文件夹" → `drive`
|
||||
用户提到"听记/AI听记/会议纪要/转写/摘要/思维导图/发言人/热词" → `minutes`
|
||||
用户提到"邮箱/邮件/发邮件/收邮件/搜邮件/查邮件" → `mail`
|
||||
用户提到"审批/请假/报销/出差/加班/同意/拒绝/撤销审批" → `oa`
|
||||
用户提到"日志/日报/周报/日志统计/写日报/提交周报/发日志/填日志" → `report`
|
||||
用户提到"待办/TODO/任务提醒/循环待办" → `todo`
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
|
||||
```bash
|
||||
# 1. 搜索同事 userId
|
||||
dws contact user search --keyword "张三" --format json
|
||||
dws contact user search --query "张三" --format json
|
||||
|
||||
# 2. 创建日程
|
||||
dws calendar event create --title "会议" \
|
||||
@@ -107,7 +107,7 @@ dws calendar participant add --event <EVENT_ID> --users <USER_ID> --format json
|
||||
|
||||
```bash
|
||||
# 1. 搜索同事 userId
|
||||
dws contact user search --keyword "张三" --format json
|
||||
dws contact user search --query "张三" --format json
|
||||
|
||||
# 2. 创建待办
|
||||
dws todo task create --title "任务内容" --executors <USER_ID> --format json
|
||||
|
||||
@@ -32,12 +32,16 @@ Flags:
|
||||
Usage:
|
||||
dws attendance summary [flags]
|
||||
Example:
|
||||
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00"
|
||||
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type month
|
||||
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type week
|
||||
Flags:
|
||||
--date string 工作日期, 格式 yyyy-MM-dd HH:mm:ss (必填)
|
||||
--user string 钉钉用户 ID (必填)
|
||||
--date string 工作日期, 格式 yyyy-MM-dd HH:mm:ss (必填)
|
||||
--stats-type string 统计类型:week(周统计)或 month(月统计)(必填,钉钉服务端业务层强制要求;CLI 层会直接拒绝缺失/非法值)
|
||||
--user string 钉钉用户 ID (必填)
|
||||
```
|
||||
|
||||
> ⚠️ **重要**:`--stats-type` 在钉钉 schema 中标记为 `required: []`(看似可选),但服务端业务层**强制要求**,不传服务端会回 `C0002 / 统计类型错误`。CLI 已在客户端层做了 fail-fast:缺失或非 `week`/`month` 的取值会直接被 CLI 拒绝,不会发出请求。
|
||||
|
||||
### 查询考勤组与考勤规则
|
||||
```
|
||||
Usage:
|
||||
|
||||
@@ -60,20 +60,22 @@ Flags:
|
||||
|
||||
---
|
||||
|
||||
## group members — 查看群成员列表
|
||||
## group members list — 查看群成员列表
|
||||
|
||||
分页查询指定群聊的成员。
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws chat group members [flags]
|
||||
dws chat group members list [flags]
|
||||
Example:
|
||||
dws chat group members --id <openconversation_id>
|
||||
dws chat group members list --id <openconversation_id>
|
||||
Flags:
|
||||
--cursor string 分页游标,首次从 0 开始
|
||||
--id string 群 ID / openconversation_id (必填)
|
||||
```
|
||||
|
||||
> ⚠️ 注意:v1.0.17 起 list 是显式子命令;旧用法 `dws chat group members --id ...` 已不再支持,请改用 `dws chat group members list --id ...`。
|
||||
|
||||
---
|
||||
|
||||
## group members add — 添加群成员
|
||||
@@ -181,7 +183,7 @@ Flags:
|
||||
|
||||
## message send — 以当前用户身份发消息
|
||||
|
||||
--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。可选 --title 作为消息标题。
|
||||
--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。必须提供 --title 作为消息标题。
|
||||
--群聊时可选 --at-all @所有人,或 --at-users 指定成员(仅群聊时生效)。
|
||||
--发送图片消息:指定 --media-id(通过 dt_media_upload 工具上传获得),自动设置 msgType=image,此时不需要传文本内容。
|
||||
|
||||
@@ -203,9 +205,10 @@ Flags:
|
||||
--group string 群聊 openconversation_id(群聊时必填)
|
||||
--user string 接收人 userId(单聊时与 --open-dingtalk-id 二选一)
|
||||
--open-dingtalk-id string 接收人 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景)
|
||||
--title string 消息标题(可选,默认「消息」)
|
||||
--title string 消息标题(必填)
|
||||
--at-all @所有人(仅群聊时生效,可选,默认 false)
|
||||
--at-users string @指定成员的 userId 列表,逗号分隔(仅群聊时生效,可选)
|
||||
--at-mobiles string @指定成员的手机号列表,逗号分隔(仅群聊时生效,可选)
|
||||
--media-id string 图片 mediaId(通过 dt_media_upload 工具上传获得,需从返回链接中去除 _宽_高.格式 后缀并加上 @ 前缀),指定后发送图片消息,不需要传文本内容
|
||||
--msg-type string 消息类型(可选,如 text/markdown/image/file;通常由 --text/--media-id/--dentry-id 自动推断)
|
||||
--dentry-id string 钉盘文件 dentryId(发送钉盘文件消息时使用,需配合 --space-id)
|
||||
@@ -218,7 +221,7 @@ Flags:
|
||||
- --text 和位置参数二选一,--text 优先
|
||||
- --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
|
||||
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
|
||||
- --at-all 和 --at-users 仅在 --group 群聊时生效;当设置--at-all时,消息内容中一定要包含对应的占位符<@all>;当设置--at-users userId1,userId2时,消息内容中一定要包含对应格式的占位符<@userId1> <@userId2>
|
||||
- --at-all / --at-users / --at-mobiles 仅在 --group 群聊时生效;当设置--at-all时,消息内容中一定要包含对应的占位符<@all>;当设置--at-users userId1,userId2时,消息内容中一定要包含对应格式的占位符<@userId1> <@userId2>
|
||||
- --media-id 指定图片 mediaId 时自动发送图片消息(msgType=image),不需要传 --text;图片单聊仅支持 --open-dingtalk-id,不支持 --user
|
||||
- 发送钉盘文件消息:传 --dentry-id + --space-id(必要时配合 --file-name / --file-size / --file-type),msg-type 自动推断为 file
|
||||
```
|
||||
@@ -229,31 +232,63 @@ Flags:
|
||||
|
||||
拉取指定群聊或单聊的会话消息内容。
|
||||
|
||||
--group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
|
||||
--group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws chat message list [flags]
|
||||
Example:
|
||||
# 拉取群聊中某个时间点之后的消息
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
|
||||
# 拉取单聊消息(通过 userId)
|
||||
dws chat message list --user <userId> --time "2025-03-01 00:00:00" --limit 50
|
||||
# 拉取单聊消息(通过 openDingTalkId)
|
||||
dws chat message list --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 50
|
||||
# 拉取某个时间点之前的消息(向过去翻页)
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --forward=false
|
||||
Flags:
|
||||
--forward true=拉给定时间之后的消息,false=拉给定时间之前的消息 (default true)
|
||||
--group string 群聊 openconversation_id(群聊时必填)
|
||||
--limit int 返回数量,不传则不限制
|
||||
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
|
||||
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss(不传则默认拉取最新消息)
|
||||
--user string 单聊用户 userId(单聊时与 --open-dingtalk-id 二选一)
|
||||
--open-dingtalk-id string 单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景)
|
||||
|
||||
注意:
|
||||
- --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
|
||||
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
|
||||
- 翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
|
||||
- 如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息,需要调用 dws chat message list-topic-replies 拉取话题的回复内容列表,openConvThreadId 作为 --topic-id 参数
|
||||
```
|
||||
|
||||
### 分页翻页说明(重要)
|
||||
|
||||
`message list` 的翻页方式与 `message list-all` **完全不同**,请勿混淆:
|
||||
|
||||
| 命令 | 翻页参数 | 翻页值来源 | 值格式 |
|
||||
|------|---------|-----------|--------|
|
||||
| `message list` | `--time` | 上一页结果中**最后一条消息的 `createTime` 字段** | `yyyy-MM-dd HH:mm:ss`(如 `"2025-03-01 14:30:00"`) |
|
||||
| `message list-all` | `--cursor` | 上一页响应中的 `nextCursor` 字段 | 字符串(如 `"abc123token"`) |
|
||||
|
||||
**翻页步骤(message list):**
|
||||
|
||||
1. **首次请求**:指定起始时间
|
||||
```bash
|
||||
dws chat message list --group <id> --time "2025-03-01 00:00:00" --limit 50 --format json
|
||||
```
|
||||
2. **检查响应**:查看 `hasMore` 字段
|
||||
- `hasMore: false` → 没有更多消息,翻页结束
|
||||
- `hasMore: true` → 还有更多消息,继续下一步
|
||||
3. **获取翻页时间**:取返回结果中**最后一条消息**的 `createTime` 字段值(如 `"2025-03-01 14:30:00"`)
|
||||
4. **下一页请求**:将该 `createTime` 作为 `--time` 传入
|
||||
```bash
|
||||
dws chat message list --group <id> --time "2025-03-01 14:30:00" --limit 50 --format json
|
||||
```
|
||||
5. 重复步骤 2-4 直到 `hasMore: false`
|
||||
|
||||
> ⚠️ **常见错误**:
|
||||
> - **不要把 `nextCursor` 传给 `--time`**:响应中的 `nextCursor` 字段(纯数字时间戳如 `1776684611219`)**不是给 `--time` 用的**。`--time` 只接受 `yyyy-MM-dd HH:mm:ss` 格式。将 `nextCursor` 传给 `--time` 会导致返回相同页面,陷入死循环。`nextCursor` 仅用于 `message list-all` 的 `--cursor` 参数。
|
||||
> - **不要把 `nextCursor` 传给 `--forward`**:`--forward` 只接受 `true`(拉给定时间之后的消息)或 `false`(拉给定时间之前的消息),不是时间戳或游标参数。
|
||||
|
||||
---
|
||||
|
||||
## message list-all — 拉取指定时间范围内当前用户的所有会话消息
|
||||
@@ -542,7 +577,7 @@ Flags:
|
||||
|
||||
用户说"建群/创建群聊" → `chat group create`
|
||||
用户说"搜索群/找群" → `chat search`
|
||||
用户说"群成员/看群里有谁" → `chat group members`
|
||||
用户说"群成员/看群里有谁" → `chat group members list`
|
||||
用户说"拉人进群/加群成员" → `chat group members add`
|
||||
用户说"踢人/移除群成员" → `chat group members remove`
|
||||
用户说"加机器人到群" → `chat group members add-bot`
|
||||
@@ -601,10 +636,10 @@ dws chat message list-unread-conversations --count 20 --format json
|
||||
dws chat message send --group <openconversation_id> --title "周报提醒" "请大家本周五前提交周报" --format json
|
||||
|
||||
# 4. 以个人身份单聊(通过 userId)
|
||||
dws chat message send --user <userId> "你好" --format json
|
||||
dws chat message send --user <userId> --title "问候" "你好" --format json
|
||||
|
||||
# 4b. 以个人身份单聊(通过 openDingTalkId,三方应用等无法获取 userId 时使用)
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> "你好" --format json
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --title "问候" "你好" --format json
|
||||
|
||||
# 5. 机器人发群消息(Markdown)
|
||||
dws chat message send-by-bot --robot-code <robot-code> \
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# 邮箱 (mail) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### 查询可用邮箱地址
|
||||
```
|
||||
Usage:
|
||||
dws mail mailbox list [flags]
|
||||
Example:
|
||||
dws mail mailbox list
|
||||
```
|
||||
|
||||
**返回字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `mailboxes` | `List[]` | 邮箱列表,每条包含邮箱地址、账号类型、所属企业 |
|
||||
|
||||
### 搜索邮件 (KQL 语法)
|
||||
```
|
||||
Usage:
|
||||
dws mail message search [flags]
|
||||
Example:
|
||||
dws mail message search --email user@company.com --query "subject:\"周报\"" --size 20
|
||||
dws mail message search --email user@company.com --query "from:alice AND date>2025-06-01T00:00:00Z" --size 10
|
||||
Flags:
|
||||
--cursor string 邮件的起始偏移标识, 其值取自响应中的nextCursor字段。""表示从头开始
|
||||
--email string 搜索目标邮箱地址 (必填)
|
||||
--query string KQL 查询表达式 (必填), 其中 date 格式需遵循 ISO8601 规范
|
||||
--size string 每页返回数量(最大限制 100, 默认 20) (必填),别名: --limit, --page-size
|
||||
```
|
||||
|
||||
KQL 查询字段: date, size, tag, folderId, isRead, hasAttachments, subject, attachname, body, from, to
|
||||
常用文件夹 ID: 1=已发送, 2=收件箱, 3=垃圾邮件, 5=草稿, 6=已删除
|
||||
|
||||
### KQL 查询字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 | 正确示例 | 错误示例 |
|
||||
|------|------|------|----------|----------|
|
||||
| `date` | ISO8601 日期时间 | 邮件日期,支持 `>` `<` `>=` `<=` 比较运算符 | `date>2025-06-01T00:00:00Z` | `date>2025-06-01`(缺少时间部分) |
|
||||
| `size` | 整数(字节数) | 邮件大小,支持 `>` `<` `>=` `<=` 比较运算符 | `size>1024` | `size>"1024"`(值不需要引号) |
|
||||
| `tag` | 字符串 | 邮件标签 | `tag:important` | `tag:""` |
|
||||
| `folderId` | 整数 | 文件夹 ID(1=已发送, 2=收件箱, 3=垃圾邮件, 5=草稿, 6=已删除) | `folderId:2` | `folderId:"收件箱"`(必须用数字 ID) |
|
||||
| `isRead` | 布尔 `true`/`false` | 是否已读 | `isRead:false` | `isRead:0`、`isRead:"false"`(不支持数字或字符串形式) |
|
||||
| `hasAttachments` | 布尔 `true`/`false` | 是否有附件 | `hasAttachments:true` | `hasAttachments:yes` |
|
||||
| `subject` | 字符串 | 邮件主题,含空格须加双引号 | `subject:周报`、`subject:"项目 进展"` | `subject:项目 进展`(含空格未加引号) |
|
||||
| `attachname` | 字符串 | 附件文件名,含空格须加双引号 | `attachname:report.pdf`、`attachname:"月度 报告.xlsx"` | `attachname:月度 报告.xlsx`(含空格未加引号) |
|
||||
| `body` | 字符串 | 邮件正文内容,含空格须加双引号 | `body:会议纪要`、`body:"Q1 总结"` | `body:Q1 总结`(含空格未加引号) |
|
||||
| `from` | 字符串(邮件地址或名称) | 发件人,支持:纯邮件地址、纯名称(含空格须加双引号)、`"名称<邮件地址>"` 格式 | `from:alice@company.com`、`from:"张 三"`、`from:"alice<a@b.com>"` | `from:张 三`(含空格未加引号) |
|
||||
| `to` | 字符串(邮件地址或名称) | 收件人,支持:纯邮件地址、纯名称(含空格须加双引号)、`"名称<邮件地址>"` 格式 | `to:bob@company.com`、`to:"李 四"`、`to:"alice<a@b.com>"` | `to:李 四`(含空格未加引号) |
|
||||
|
||||
**组合查询说明:**
|
||||
- 支持 `AND` / `OR` / `NOT` 逻辑运算符(大写)
|
||||
- 括号用于分组:`(from:alice OR from:bob) AND folderId:2`
|
||||
- 排除特定文件夹:`(NOT folderId:3) AND (NOT folderId:6)`
|
||||
|
||||
### message search 返回值说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `messages` | `List[]` | 邮件列表,每条包含邮件 ID 及元信息(不含正文) |
|
||||
| `total` | `int32` | 符合条件的总邮件数 |
|
||||
| `nextCursor` | `string` | 下一页游标,传入 `--cursor` 翻页;值为 `$` 表示已到达列表尾部 |
|
||||
|
||||
**翻页示例:**
|
||||
```bash
|
||||
# 第一页
|
||||
dws mail message search --email user@company.com --query "folderId:2" --size 20 --format json
|
||||
# 取返回中的 nextCursor,传入下一次请求(nextCursor="$" 时停止)
|
||||
dws mail message search --email user@company.com --query "folderId:2" --size 20 --cursor <nextCursor> --format json
|
||||
```
|
||||
|
||||
### 查看邮件完整内容
|
||||
```
|
||||
Usage:
|
||||
dws mail message get [flags]
|
||||
Example:
|
||||
dws mail message get --email user@company.com --id <messageId>
|
||||
Flags:
|
||||
--email string 邮件所属邮箱地址 (必填)
|
||||
--id string 邮件 ID (必填)
|
||||
```
|
||||
|
||||
**返回字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `message` | `object` | 邮件完整信息,包含主题、发件人、收件人、正文、附件等 |
|
||||
|
||||
### 发送邮件
|
||||
```
|
||||
Usage:
|
||||
dws mail message send [flags]
|
||||
Example:
|
||||
dws mail message send --from user@company.com --to colleague@company.com \
|
||||
--subject "周报" --body "本周完成任务A和任务B"
|
||||
Flags:
|
||||
--body string 邮件正文,支持 Markdown 格式 (必填)
|
||||
--cc string 抄送人列表
|
||||
--from string 发件人邮箱 (必填),别名: --sender
|
||||
--subject string 邮件标题 (必填)
|
||||
--to string 收件人列表 (必填)
|
||||
```
|
||||
|
||||
## 通用错误说明
|
||||
|
||||
以下错误适用于所有 mail 命令。
|
||||
|
||||
| 错误标识 | 含义 | 处理建议 |
|
||||
|----------|------|----------|
|
||||
| `domain.notFound` | 该用户的邮箱不是由钉钉邮箱托管,无法完成操作 | 确认邮箱是否已开通钉钉企业邮箱服务 |
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"我的邮箱/邮箱地址" → `mailbox list`
|
||||
用户说"找邮件/搜邮件/查邮件" → `message search`
|
||||
用户说"看邮件/打开邮件/邮件内容" → 先 `message search` 获取 messageId,再 `message get`
|
||||
用户说"发邮件/写邮件" → 先 `mailbox list` 获取发件地址,再 `message send`
|
||||
用户说"给(某人名字)发邮件" → 先 `aisearch person` 获取 userId,再 `contact user get` 获取收件人邮箱,再 `message send`
|
||||
|
||||
|
||||
## 严格禁止 (NEVER DO)
|
||||
- 明确禁止猜测、假设、推断发件人和收件人邮箱
|
||||
- 无法获取邮箱时,强引导ask_human,由用户确认,不要通过假设或其他方式继续执行
|
||||
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
# 1. 查看可用邮箱 — 提取邮箱地址
|
||||
dws mail mailbox list --format json
|
||||
|
||||
# 2. 搜索邮件 — 提取 messageId
|
||||
dws mail message search --email user@company.com \
|
||||
--query "subject:\"周报\" AND date>2025-06-01T00:00:00Z" --size 10 --format json
|
||||
|
||||
# 3. 查看邮件详情
|
||||
dws mail message get --email user@company.com --id <messageId> --format json
|
||||
|
||||
# 4. 发送邮件
|
||||
dws mail message send --from user@company.com --to colleague@company.com \
|
||||
--subject "周报" --body "本周完成…" --format json
|
||||
```
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `mailbox list` | 邮箱地址 | message search/get/send 的 --email/--from |
|
||||
| `message search` | `messageId` | message get 的 --id |
|
||||
| `aisearch person` → `contact user get` | 用户邮箱 (orgAuthEmail) | message send 的 --to/--cc (跨产品) |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `mailbox list` 返回用户所有邮箱(含个人和企业),每条记录包含邮箱地址、账号类型、所属企业。选择邮箱时优先匹配用户当前所在企业的企业邮箱;若有多个可选,向用户确认后再操作
|
||||
- `message search` 返回邮件 ID 和元信息(不含正文),需 `message get` 获取完整内容
|
||||
- KQL 查询支持 AND/OR/NOT 组合,字段值含空格时需用双引号
|
||||
- `--cc` 抄送人支持多人,逗号分隔
|
||||
- 收件人邮箱获取:用户只知道同事名字时,先通过 `dws aisearch person --keyword "名字" --dimension name` 获取 userId,再 `dws contact user get --ids <userId>` 从返回中提取 orgAuthEmail 字段
|
||||
|
||||
|
||||
@@ -18,16 +18,6 @@ Flags:
|
||||
--size string 每页数量 (默认 10)
|
||||
```
|
||||
|
||||
### 搜索错误码
|
||||
```
|
||||
Usage:
|
||||
dws devdoc article search-error [flags]
|
||||
Example:
|
||||
dws devdoc article search-error --keyword "403" --format json
|
||||
Flags:
|
||||
--keyword string 错误码或关键词 (必填)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## oa — 审批
|
||||
@@ -67,17 +57,17 @@ Example:
|
||||
### 查询待我处理的审批
|
||||
```
|
||||
Usage:
|
||||
dws oa approval pending [flags]
|
||||
dws oa approval list-pending [flags]
|
||||
Example:
|
||||
dws oa approval pending --format json
|
||||
dws oa approval list-pending --format json
|
||||
```
|
||||
|
||||
### 查询我发起的审批
|
||||
```
|
||||
Usage:
|
||||
dws oa approval initiated [flags]
|
||||
dws oa approval list-initiated [flags]
|
||||
Example:
|
||||
dws oa approval initiated --format json
|
||||
dws oa approval list-initiated --format json
|
||||
```
|
||||
|
||||
### 同意审批
|
||||
@@ -109,13 +99,12 @@ Example:
|
||||
## 意图判断
|
||||
|
||||
- 用户说"开发文档/API 文档/接口文档" → `devdoc article search`
|
||||
- 用户说"API 报错/错误码" → `devdoc article search-error`
|
||||
- 用户说"审批/请假/报销/出差" → `oa approval`
|
||||
- 用户说"同意审批/批准" → `oa approval approve`
|
||||
- 用户说"拒绝审批/驳回" → `oa approval reject`
|
||||
- 用户说"撤销审批/撤回" → `oa approval revoke`
|
||||
- 用户说"待我审批/我要审批的" → `oa approval pending` 或 `oa approval tasks`
|
||||
- 用户说"我发起的审批" → `oa approval initiated`
|
||||
- 用户说"待我审批/我要审批的" → `oa approval list-pending` 或 `oa approval tasks`
|
||||
- 用户说"我发起的审批" → `oa approval list-initiated`
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
@@ -124,5 +113,5 @@ Example:
|
||||
| `devdoc article search` | 文档链接 | 直接展示给用户 |
|
||||
| `oa approval list-forms` | processCode | detail / records 等 |
|
||||
| `oa approval tasks` | taskId, instanceId | approve / reject |
|
||||
| `oa approval pending` | instanceId | detail / approve / reject |
|
||||
| `oa approval initiated` | instanceId | detail / revoke |
|
||||
| `oa approval list-pending` | instanceId | detail / approve / reject |
|
||||
| `oa approval list-initiated` | instanceId | detail / revoke |
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 工作台 (workbench) 命令参考
|
||||
|
||||
> ⚠️ **Draft**:以下命令在当前 CLI 运行时中尚未上线(`dws workbench --help` 回退到根帮助页面),文档仅供预览,待服务端注册后生效。
|
||||
|
||||
## 命令总览
|
||||
|
||||
### 查看所有工作台应用
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package cli_compat_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -146,7 +148,11 @@ func TestAttendanceShiftList_should_filter_empty_user_ids(t *testing.T) {
|
||||
func TestAttendanceSummary_should_call_tool_with_user_and_date(t *testing.T) {
|
||||
cap := setupTestDeps(t, "attendance")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{"user": "U001", "date": "2026-03-12 15:00:00"})
|
||||
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
|
||||
"user": "U001",
|
||||
"date": "2026-03-12 15:00:00",
|
||||
"stats-type": "month",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -157,8 +163,9 @@ func TestAttendanceSummary_should_pass_user_and_date_flags(t *testing.T) {
|
||||
cap := setupTestDeps(t, "attendance")
|
||||
root := buildRoot()
|
||||
_ = execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
|
||||
"user": "U001",
|
||||
"date": "2026-03-12 15:00:00",
|
||||
"user": "U001",
|
||||
"date": "2026-03-12 15:00:00",
|
||||
"stats-type": "month",
|
||||
})
|
||||
last := cap.last()
|
||||
if last == nil {
|
||||
@@ -200,10 +207,14 @@ func TestAttendanceSummary_should_error_when_user_missing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttendanceSummary_should_pass_only_user_flag(t *testing.T) {
|
||||
func TestAttendanceSummary_should_pass_user_id_through_vo(t *testing.T) {
|
||||
cap := setupTestDeps(t, "attendance")
|
||||
root := buildRoot()
|
||||
_ = execCmd(t, root, []string{"attendance", "summary"}, map[string]string{"user": "U002", "date": "2026-03-12 15:00:00"})
|
||||
_ = execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
|
||||
"user": "U002",
|
||||
"date": "2026-03-12 15:00:00",
|
||||
"stats-type": "month",
|
||||
})
|
||||
last := cap.last()
|
||||
if last == nil {
|
||||
t.Fatal("no call captured")
|
||||
@@ -220,7 +231,11 @@ func TestAttendanceSummary_should_pass_only_user_flag(t *testing.T) {
|
||||
func TestAttendanceSummary_should_use_dry_run_mode(t *testing.T) {
|
||||
cap := setupTestDepsWithDryRun(t, "attendance")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{"user": "U001", "date": "2026-03-12 15:00:00"})
|
||||
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
|
||||
"user": "U001",
|
||||
"date": "2026-03-12 15:00:00",
|
||||
"stats-type": "month",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -229,6 +244,97 @@ func TestAttendanceSummary_should_use_dry_run_mode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// execSummaryDryRun runs attendance summary with --dry-run and returns the
|
||||
// parsed QueryUserAttendVO map from the helper_invocation payload. This is
|
||||
// necessary because the attendance handler is a custom helper (not a dynamic
|
||||
// MCP-route), so the test framework's mcpCallCapture cannot intercept the
|
||||
// call in non-dry-run mode.
|
||||
func execSummaryDryRun(t *testing.T, flags map[string]string) map[string]any {
|
||||
t.Helper()
|
||||
root := buildRoot()
|
||||
_ = setupTestDeps(t, "attendance")
|
||||
|
||||
cliArgs := []string{"-f", "json", "attendance", "summary", "--dry-run"}
|
||||
for k, v := range flags {
|
||||
if v != "" {
|
||||
cliArgs = append(cliArgs, "--"+k, v)
|
||||
}
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs(cliArgs)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected CLI error: %v", err)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Params map[string]any `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("failed to parse dry-run output as JSON: %v\noutput: %s", err, out.String())
|
||||
}
|
||||
vo, ok := payload.Params["QueryUserAttendVO"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected QueryUserAttendVO map in dry-run params, got %T: %v", payload.Params["QueryUserAttendVO"], payload.Params)
|
||||
}
|
||||
return vo
|
||||
}
|
||||
|
||||
func TestAttendanceSummary_should_pass_stats_type_when_provided(t *testing.T) {
|
||||
vo := execSummaryDryRun(t, map[string]string{
|
||||
"user": "U001",
|
||||
"date": "2026-03-12 15:00:00",
|
||||
"stats-type": "month",
|
||||
})
|
||||
if vo["statsType"] != "month" {
|
||||
t.Errorf("expected statsType=month in VO, got %v", vo["statsType"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttendanceSummary_should_pass_stats_type_week(t *testing.T) {
|
||||
vo := execSummaryDryRun(t, map[string]string{
|
||||
"user": "U001",
|
||||
"date": "2026-03-12 15:00:00",
|
||||
"stats-type": "week",
|
||||
})
|
||||
if vo["statsType"] != "week" {
|
||||
t.Errorf("expected statsType=week in VO, got %v", vo["statsType"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttendanceSummary_should_error_when_stats_type_missing(t *testing.T) {
|
||||
_ = setupTestDeps(t, "attendance")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
|
||||
"user": "U001",
|
||||
"date": "2026-03-12 15:00:00",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --stats-type is missing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stats-type") {
|
||||
t.Errorf("expected error message to mention stats-type, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttendanceSummary_should_error_when_stats_type_invalid(t *testing.T) {
|
||||
_ = setupTestDeps(t, "attendance")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
|
||||
"user": "U001",
|
||||
"date": "2026-03-12 15:00:00",
|
||||
"stats-type": "foobar",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --stats-type is neither week nor month")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "week") || !strings.Contains(err.Error(), "month") {
|
||||
t.Errorf("expected error message to mention week and month, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── attendance rules ───────────────────────────────────────
|
||||
|
||||
func TestAttendanceRules_should_call_tool_with_date(t *testing.T) {
|
||||
|
||||
@@ -258,7 +258,7 @@ func TestTodoTaskGet_should_call_query_todo_detail(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertToolName(t, cap, "query_todo_detail")
|
||||
assertToolName(t, cap, "get_todo_detail")
|
||||
}
|
||||
|
||||
func TestTodoTaskGet_should_pass_taskId(t *testing.T) {
|
||||
|
||||
@@ -23,6 +23,7 @@ var expectedPackagedSkillTargets = []string{
|
||||
".kiro/skills/dws",
|
||||
".trae/skills/dws",
|
||||
".openclaw/skills/dws",
|
||||
".hermes/skills/dws",
|
||||
}
|
||||
|
||||
// seedDistArtifacts creates fake goreleaser output archives (empty tar.gz/zip
|
||||
|
||||
+8
-8
@@ -776,13 +776,13 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
|
||||
|
||||
**contact_contact_dept_search_001**
|
||||
- Prompt: 搜索部门,keyword 为 技术部
|
||||
- Expected: `dws contact dept search --keyword 技术部 --format json`
|
||||
- Flags: `--keyword` = `技术部`
|
||||
- Expected: `dws contact dept search --query 技术部 --format json`
|
||||
- Flags: `--query` = `技术部`
|
||||
|
||||
**contact_contact_dept_search_002**
|
||||
- Prompt: 搜一下技术部的部门信息
|
||||
- Expected: `dws contact dept search --keyword "技术部" --format json`
|
||||
- Flags: `--keyword` = `技术部`
|
||||
- Expected: `dws contact dept search --query "技术部" --format json`
|
||||
- Flags: `--query` = `技术部`
|
||||
|
||||
#### `dws contact user get`
|
||||
|
||||
@@ -810,13 +810,13 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
|
||||
|
||||
**contact_contact_user_search_001**
|
||||
- Prompt: 按关键词搜索用户,keyword 为 张三
|
||||
- Expected: `dws contact user search --keyword 张三 --format json`
|
||||
- Flags: `--keyword` = `张三`
|
||||
- Expected: `dws contact user search --query 张三 --format json`
|
||||
- Flags: `--query` = `张三`
|
||||
|
||||
**contact_contact_user_search_002**
|
||||
- Prompt: 帮我找一下张三的联系方式
|
||||
- Expected: `dws contact user search --keyword "张三" --format json`
|
||||
- Flags: `--keyword` = `张三`
|
||||
- Expected: `dws contact user search --query "张三" --format json`
|
||||
- Flags: `--query` = `张三`
|
||||
|
||||
#### `dws contact user search-mobile`
|
||||
|
||||
|
||||
@@ -1014,7 +1014,7 @@
|
||||
**contact_contact_dept_search_001** ✅ PASS
|
||||
|
||||
- Prompt: 搜索部门,keyword 为 技术部
|
||||
- Expected: `dws contact dept search --keyword 技术部 --format json`
|
||||
- Expected: `dws contact dept search --query 技术部 --format json`
|
||||
- Skill Reference: references/products/contact.md
|
||||
- Command path: PASS (contact dept search)
|
||||
- Flags: PASS (1 flags validated)
|
||||
@@ -1022,7 +1022,7 @@
|
||||
**contact_contact_dept_search_002** ✅ PASS
|
||||
|
||||
- Prompt: 搜一下技术部的部门信息
|
||||
- Expected: `dws contact dept search --keyword "技术部" --format json`
|
||||
- Expected: `dws contact dept search --query "技术部" --format json`
|
||||
- Skill Reference: references/products/contact.md
|
||||
- Command path: PASS (contact dept search)
|
||||
- Flags: PASS (1 flags validated)
|
||||
@@ -1062,7 +1062,7 @@
|
||||
**contact_contact_user_search_001** ✅ PASS
|
||||
|
||||
- Prompt: 按关键词搜索用户,keyword 为 张三
|
||||
- Expected: `dws contact user search --keyword 张三 --format json`
|
||||
- Expected: `dws contact user search --query 张三 --format json`
|
||||
- Skill Reference: references/products/contact.md
|
||||
- Command path: PASS (contact user search)
|
||||
- Flags: PASS (1 flags validated)
|
||||
@@ -1070,7 +1070,7 @@
|
||||
**contact_contact_user_search_002** ✅ PASS
|
||||
|
||||
- Prompt: 帮我找一下张三的联系方式
|
||||
- Expected: `dws contact user search --keyword "张三" --format json`
|
||||
- Expected: `dws contact user search --query "张三" --format json`
|
||||
- Skill Reference: references/products/contact.md
|
||||
- Command path: PASS (contact user search)
|
||||
- Flags: PASS (1 flags validated)
|
||||
|
||||
@@ -10,6 +10,14 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOpenSourceTreeOmitsEmbeddedHostMarkers scans source files for proprietary
|
||||
// markers that must not leak into the public (open-source) tree.
|
||||
//
|
||||
// The scanner covers public docs as well as source code because OSS leakage is
|
||||
// often introduced through documentation first. Only a small set of explicitly
|
||||
// documented compatibility literals (see NOTE below) are allowed to remain in
|
||||
// tree; everything else should trip the guard regardless of whether it appears
|
||||
// in Go, shell, YAML, templates, or markdown.
|
||||
func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) {
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
@@ -17,6 +25,14 @@ func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) {
|
||||
}
|
||||
|
||||
root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
|
||||
// NOTE: REWIND_SESSION_ID / REWIND_REQUEST_ID / REWIND_MESSAGE_ID are
|
||||
// intentionally NOT on this list. They are accepted by the CLI as
|
||||
// optional backward-compatibility aliases for the primary DWS_* trace
|
||||
// env names. Because they are a documented compatibility surface
|
||||
// rather than an internal coupling to a specific host implementation,
|
||||
// referring to these literals from source code and docs is allowed.
|
||||
// Product names like "RewindDesktop" and other host-implementation
|
||||
// specific symbols remain forbidden.
|
||||
forbidden := []string{
|
||||
"DWS_" + "BUILD_MODE",
|
||||
"com.dingtalk.scenario." + "wukong",
|
||||
@@ -27,12 +43,6 @@ func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) {
|
||||
"EnablePrivate" + "UtilityCommands",
|
||||
"UseExecutable" + "ConfigDir",
|
||||
"DeleteExeRelative" + "TokenOnAuthErr",
|
||||
"WriteToken" + "Marker",
|
||||
"Token" + "Marker",
|
||||
"tokenJSON" + "File",
|
||||
"REWIND_" + "REQUEST_ID",
|
||||
"REWIND_" + "SESSION_ID",
|
||||
"REWIND_" + "MESSAGE_ID",
|
||||
"MergeWukong" + "MCPHeaders",
|
||||
"buildMode ==" + " \"real\"",
|
||||
"wukong/" + "discovery",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package unit_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
)
|
||||
|
||||
// TestHostOwnsPATFlow_OnlySignal is the wire-level guard for the
|
||||
// "custom authorization card" contract: the CLI switches to host-owned
|
||||
// PAT mode iff the host injects DINGTALK_DWS_AGENTCODE. DINGTALK_AGENT /
|
||||
// claw-type is purely a server-side routing tag and must NOT influence
|
||||
// the decision, in either direction.
|
||||
//
|
||||
// Regression guard: several earlier drafts conflated the two signals,
|
||||
// causing third-party Agent hosts that only set DINGTALK_DWS_AGENTCODE
|
||||
// to still see a browser popup (CLI-owned fallback). This table locks
|
||||
// in the decoupling.
|
||||
func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
agentCode string
|
||||
agentEnv string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "no signal → CLI-owned",
|
||||
agentCode: "",
|
||||
agentEnv: "",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "agent code only → host-owned",
|
||||
agentCode: "agt-cursor",
|
||||
agentEnv: "",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "agent code + DINGTALK_AGENT=default → host-owned",
|
||||
agentCode: "agt-cursor",
|
||||
agentEnv: "default",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "agent code + DINGTALK_AGENT=business → host-owned",
|
||||
agentCode: "agt-cursor",
|
||||
agentEnv: "sales-copilot",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "DINGTALK_AGENT=business, no agent code → CLI-owned",
|
||||
agentCode: "",
|
||||
agentEnv: "sales-copilot",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "DINGTALK_AGENT=default, no agent code → CLI-owned",
|
||||
agentCode: "",
|
||||
agentEnv: "default",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "whitespace-only agent code → CLI-owned",
|
||||
agentCode: " ",
|
||||
agentEnv: "sales-copilot",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, tc.agentCode)
|
||||
// DINGTALK_AGENT is set purely to demonstrate that it does NOT
|
||||
// influence the host-owned decision. The literal env name is
|
||||
// used here because the auth package no longer exports a
|
||||
// DingTalkAgentEnv constant (it is not part of the PAT
|
||||
// decision surface).
|
||||
t.Setenv("DINGTALK_AGENT", tc.agentEnv)
|
||||
|
||||
if got := authpkg.HostOwnsPATFlow(); got != tc.want {
|
||||
t.Fatalf(
|
||||
"HostOwnsPATFlow() = %v, want %v (agentCode=%q, DINGTALK_AGENT=%q)",
|
||||
got, tc.want, tc.agentCode, tc.agentEnv,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package unit_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
errpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
// D5 Smoke A — PAT stderr JSON is a single-line, json.Unmarshal-able
|
||||
// payload for every code in the frozen enum.
|
||||
//
|
||||
// This test is the CI-level guard for the wire invariant ("stderr JSON
|
||||
// MUST be single-line and directly unmarshal-able"). It sits in the
|
||||
// public `test/unit` tree — deliberately separate from
|
||||
// internal/errors/pat_test.go — so the contract stays defended even
|
||||
// after internal refactors that might move the JSON assembly code to
|
||||
// another package or collapse helpers into it.
|
||||
//
|
||||
// Wire path exercised: ClassifyPatAuthCheck covers BOTH error families
|
||||
// the SSOT freezes — patNoPermissionCodes (PAT_NO_PERMISSION and the
|
||||
// three risk-tier variants) and patAuthRequiredCodes
|
||||
// (PAT_SCOPE_AUTH_REQUIRED, AGENT_CODE_NOT_EXISTS). Using this single
|
||||
// entry point keeps the test format-agnostic to future code-churn in
|
||||
// ClassifyMCPResponseText's branching while still covering the full
|
||||
// SSOT code enum.
|
||||
//
|
||||
// What we assert for every case:
|
||||
// 1. Non-nil *PATError is returned with ExitCode == 4.
|
||||
// 2. RawJSON has NO '\n' characters (single-line invariant).
|
||||
// 3. RawJSON is directly json.Unmarshal-able into a map.
|
||||
// 4. Parsed body has success=false and code matching the fixture.
|
||||
// 5. Required downstream data shape is present (requiredScopes for
|
||||
// PAT_*_NO_PERMISSION; missingScope for PAT_SCOPE_AUTH_REQUIRED).
|
||||
func TestPATStderrJSON_SingleLineUnmarshalable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type assertFn func(t *testing.T, parsed map[string]any)
|
||||
|
||||
requiredScopesPresent := func(t *testing.T, parsed map[string]any) {
|
||||
t.Helper()
|
||||
data, ok := parsed["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("parsed.data missing or wrong type: %v", parsed["data"])
|
||||
}
|
||||
scopes, ok := data["requiredScopes"].([]any)
|
||||
if !ok || len(scopes) == 0 {
|
||||
t.Fatalf("parsed.data.requiredScopes missing or empty: %v", data["requiredScopes"])
|
||||
}
|
||||
for i, s := range scopes {
|
||||
if _, ok := s.(string); !ok {
|
||||
t.Fatalf("parsed.data.requiredScopes[%d] is not a string: %v", i, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
missingScopePresent := func(t *testing.T, parsed map[string]any) {
|
||||
t.Helper()
|
||||
data, ok := parsed["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("parsed.data missing or wrong type: %v", parsed["data"])
|
||||
}
|
||||
scope, ok := data["missingScope"].(string)
|
||||
if !ok || scope == "" {
|
||||
t.Fatalf("parsed.data.missingScope missing or not a string: %v", data["missingScope"])
|
||||
}
|
||||
}
|
||||
|
||||
agentCodeFieldPresent := func(t *testing.T, parsed map[string]any) {
|
||||
t.Helper()
|
||||
data, ok := parsed["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("parsed.data missing or wrong type: %v", parsed["data"])
|
||||
}
|
||||
if _, ok := data["agentCode"].(string); !ok {
|
||||
t.Fatalf("parsed.data.agentCode missing or not a string: %v", data["agentCode"])
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
code string
|
||||
body map[string]any
|
||||
extract assertFn
|
||||
}{
|
||||
{
|
||||
name: "PAT_NO_PERMISSION generic baseline",
|
||||
code: "PAT_NO_PERMISSION",
|
||||
body: map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"requiredScopes": []any{"aitable.record:read"},
|
||||
"grantOptions": []any{"session", "permanent"},
|
||||
"displayName": "阅读多维表记录",
|
||||
"productName": "AI 表格",
|
||||
},
|
||||
},
|
||||
extract: requiredScopesPresent,
|
||||
},
|
||||
{
|
||||
name: "PAT_LOW_RISK_NO_PERMISSION",
|
||||
code: "PAT_LOW_RISK_NO_PERMISSION",
|
||||
body: map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_LOW_RISK_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"requiredScopes": []any{"aitable.record:read"},
|
||||
"grantOptions": []any{"session", "permanent"},
|
||||
"displayName": "阅读多维表记录",
|
||||
"productName": "AI 表格",
|
||||
},
|
||||
},
|
||||
extract: requiredScopesPresent,
|
||||
},
|
||||
{
|
||||
name: "PAT_MEDIUM_RISK_NO_PERMISSION",
|
||||
code: "PAT_MEDIUM_RISK_NO_PERMISSION",
|
||||
body: map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_MEDIUM_RISK_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"requiredScopes": []any{"chat.group:write"},
|
||||
"grantOptions": []any{"session", "permanent"},
|
||||
"displayName": "发送群消息",
|
||||
"productName": "群聊",
|
||||
},
|
||||
},
|
||||
extract: requiredScopesPresent,
|
||||
},
|
||||
{
|
||||
name: "PAT_HIGH_RISK_NO_PERMISSION",
|
||||
code: "PAT_HIGH_RISK_NO_PERMISSION",
|
||||
body: map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_HIGH_RISK_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"requiredScopes": []any{"finance.invoice:write"},
|
||||
"grantOptions": []any{"once"},
|
||||
"displayName": "开具发票",
|
||||
"productName": "财务",
|
||||
"authRequestId": "auth-req-xxxx",
|
||||
},
|
||||
},
|
||||
extract: requiredScopesPresent,
|
||||
},
|
||||
{
|
||||
name: "PAT_SCOPE_AUTH_REQUIRED",
|
||||
code: "PAT_SCOPE_AUTH_REQUIRED",
|
||||
body: map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_SCOPE_AUTH_REQUIRED",
|
||||
"data": map[string]any{
|
||||
"missingScope": "Contact.User.Read",
|
||||
"hint": "run `dws auth login --scope Contact.User.Read`",
|
||||
},
|
||||
},
|
||||
extract: missingScopePresent,
|
||||
},
|
||||
{
|
||||
name: "AGENT_CODE_NOT_EXISTS",
|
||||
code: "AGENT_CODE_NOT_EXISTS",
|
||||
body: map[string]any{
|
||||
"success": false,
|
||||
"code": "AGENT_CODE_NOT_EXISTS",
|
||||
"data": map[string]any{
|
||||
"agentCode": "agt-unknown",
|
||||
"hint": "请检查 DINGTALK_DWS_AGENTCODE / agent 注册",
|
||||
},
|
||||
},
|
||||
extract: agentCodeFieldPresent,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
patErr := errpkg.ClassifyPatAuthCheck(tc.body)
|
||||
if patErr == nil {
|
||||
t.Fatalf("ClassifyPatAuthCheck(%s) returned nil; expected *PATError", tc.code)
|
||||
}
|
||||
if patErr.ExitCode() != errpkg.ExitCodePermission {
|
||||
t.Fatalf("ExitCode() = %d, want %d (PAT exit_code MUST be 4)",
|
||||
patErr.ExitCode(), errpkg.ExitCodePermission)
|
||||
}
|
||||
|
||||
raw := patErr.RawStderr()
|
||||
if raw == "" {
|
||||
t.Fatalf("RawStderr() is empty; expected single-line JSON")
|
||||
}
|
||||
if strings.ContainsAny(raw, "\n\r") {
|
||||
t.Fatalf("RawStderr() MUST be single-line (no \\n or \\r); got:\n%q", raw)
|
||||
}
|
||||
if strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t") {
|
||||
t.Fatalf("RawStderr() MUST not have leading whitespace; got: %q", raw)
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
t.Fatalf("json.Unmarshal(RawStderr()) error = %v; raw=%q", err, raw)
|
||||
}
|
||||
|
||||
if success, ok := parsed["success"].(bool); !ok || success {
|
||||
t.Fatalf("parsed.success = %v, want false", parsed["success"])
|
||||
}
|
||||
gotCode, ok := parsed["code"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("parsed.code missing or not a string: %v", parsed["code"])
|
||||
}
|
||||
if gotCode != tc.code {
|
||||
t.Fatalf("parsed.code = %q, want %q", gotCode, tc.code)
|
||||
}
|
||||
|
||||
tc.extract(t, parsed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPATStderrJSON_CodeEnumFrozen pins the exact code enum frozen for
|
||||
// PAT-family errors (see patNoPermissionCodes / patAuthRequiredCodes in
|
||||
// internal/errors/pat.go). If a future refactor accidentally renames a
|
||||
// code (e.g. drops the risk-tier prefix) the classifier will stop
|
||||
// producing a *PATError and this table-driven loop will fail with a
|
||||
// clear message — preventing a silent break of the host integration
|
||||
// contract.
|
||||
func TestPATStderrJSON_CodeEnumFrozen(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
frozen := []string{
|
||||
"PAT_NO_PERMISSION",
|
||||
"PAT_LOW_RISK_NO_PERMISSION",
|
||||
"PAT_MEDIUM_RISK_NO_PERMISSION",
|
||||
"PAT_HIGH_RISK_NO_PERMISSION",
|
||||
"PAT_SCOPE_AUTH_REQUIRED",
|
||||
"AGENT_CODE_NOT_EXISTS",
|
||||
}
|
||||
|
||||
for _, code := range frozen {
|
||||
code := code
|
||||
t.Run(code, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{
|
||||
"success": false,
|
||||
"code": code,
|
||||
"data": map[string]any{"requiredScopes": []any{"stub.entity:read"}},
|
||||
}
|
||||
if patErr := errpkg.ClassifyPatAuthCheck(body); patErr == nil {
|
||||
t.Fatalf("ClassifyPatAuthCheck(%q) returned nil — code was removed from the SSOT-frozen enum", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user