Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
754b0df056 | ||
|
|
6be124777f | ||
|
|
355a1460d9 | ||
|
|
c6edc84e40 | ||
|
|
f497047fff | ||
|
|
a9de7d3ca4 | ||
|
|
1c200d883f | ||
|
|
d268524084 | ||
|
|
ed4673e7d2 | ||
|
|
de723914a5 | ||
|
|
649801e479 | ||
|
|
49c5bea4f3 | ||
|
|
6707e56f9c | ||
|
|
2ba1dcdda4 | ||
|
|
1637ae16c7 | ||
|
|
eee19d7347 | ||
|
|
19f7b59ffb | ||
|
|
c4952d0207 | ||
|
|
ecf2684f58 | ||
|
|
9e9b898dd2 | ||
|
|
17f692e7f1 | ||
|
|
574d9aa2f7 | ||
|
|
1aaaef0274 | ||
|
|
00c037b5be | ||
|
|
9e15115ad4 | ||
|
|
25bf3d12f2 | ||
|
|
f78cc5c846 | ||
|
|
72fe795f3f | ||
|
|
0e892c7d75 | ||
|
|
8995bf65d6 | ||
|
|
91af2bc3b8 | ||
|
|
e2e8b3bf52 | ||
|
|
a652b90fd4 | ||
|
|
7a868ddf39 | ||
|
|
89d7c5f11b | ||
|
|
efb61cae02 | ||
|
|
fb88c6ace9 | ||
|
|
5258959a14 | ||
|
|
c515fc1001 | ||
|
|
7692048cf4 | ||
|
|
426810a776 | ||
|
|
f253841cf7 | ||
|
|
da2522c8ca | ||
|
|
43715e28b0 | ||
|
|
d11aa0f030 | ||
|
|
41396426d0 | ||
|
|
6946929e0b | ||
|
|
6756ead38a | ||
|
|
4c44a2b632 | ||
|
|
c0468754a4 | ||
|
|
e06b1d68ff | ||
|
|
b101d5b12b | ||
|
|
acc5dc8a3c | ||
|
|
592749c986 | ||
|
|
9b6174e03e | ||
|
|
f73fa08e49 | ||
|
|
bb5e5a40d9 | ||
|
|
e3b0c9b1ed | ||
|
|
cc1f0a47e3 | ||
|
|
b0c9160ed5 | ||
|
|
0b544a86b7 | ||
|
|
d71031d097 | ||
|
|
46192d68ba | ||
|
|
eeb1782f7a | ||
|
|
4d0e194861 | ||
|
|
3046ff8e77 | ||
|
|
ad33a46883 | ||
|
|
bbca507792 | ||
|
|
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 |
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -16,6 +16,7 @@ dws
|
||||
test/cli/testdata/
|
||||
tmp/
|
||||
test/cli_compat/testdata/
|
||||
/internal/compat/testdata/*
|
||||
.gitignore
|
||||
.worktrees/
|
||||
|
||||
|
||||
+303
@@ -4,6 +4,305 @@ 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.30] - 2026-05-19
|
||||
|
||||
Aligns the open-source CLI with the IM envelope and schema-pipeline plumbing the Wukong edition has been running in pre-prod, plus three user-visible quality-of-life fixes. The most visible one: chat-bot webhook payloads carrying literal Chinese mentions (`@所有人 周报来了` / `@张三 看一下`) no longer fail with `file not found` — `@` is only treated as the `@<filename>` file-injection prefix when followed by an ASCII path-shaped character. The `chat` command tree is refactored to lean on the service-discovery envelope: thin wrappers (`chat search`, `chat group rename`, `chat group members list/add/remove/add-bot`, `chat bot search`) move out of the hardcoded helper and become envelope-generated dynamic commands; the helper keeps only the chat commands with real business logic (intelligent routing, current-user resolution, response normalization, stdin/@file input). A new `dws chat message reply` joins the existing `send` / `send-by-bot` / `recall-by-bot` / `send-by-webhook` family. Underneath: `transform: invert_bool` lets envelopes flip boolean semantics between CLI surface and MCP body (e.g. `--off` ↔ `mute=true`); the pipeline executor fail-fast on upstream `content.errorCode` instead of polling forever; service-discovery dedup keeps two envelope entries that share an MCP endpoint but declare different `cli.id` as separate descriptors (so the `bot-root` / `bot-message` / `bot-group` trio fronting one MCP server stays as three distinct CLI command roots); and `dws chat` no longer nests as `dws chat chat` when two envelope servers both declare the same top-level command name.
|
||||
|
||||
### Added
|
||||
|
||||
- **`transform: invert_bool` for envelope flag overrides** (#317, see `internal/compat/transform.go`) — flips a boolean at send time. Strings `true`/`1`/`yes`/`on` → `false`; `false`/`0`/`no`/`off`/`""` → `true`. Used when the CLI surface and the MCP body have opposite semantics — e.g. envelope declares `--off` on the CLI but the MCP parameter is `mute=true` for "muted". The framework flips at send time so the envelope keeps the natural CLI verb without forcing every caller to remember the inverted mapping. Coverage in `internal/compat/transform_test.go`.
|
||||
- **`dws chat message reply`** (#317, see `internal/helpers/chat.go`) — reply to a chat message. Sits alongside `send` / `send-by-bot` / `recall-by-bot` / `send-by-webhook` under `dws chat message`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`chat` command tree refactored to lean on the service-discovery envelope** (#317, commit `6be1247`) — `internal/helpers/chat.go` now only carries the chat commands that need real business logic on top of the raw MCP call: `chat message send` (current-user resolution + symmetric direct/group title validation), `chat message send-by-bot` / `recall-by-bot` / `send-by-webhook` (bot routing + stdin/@file input), and `chat group create` (response normalization). The thin wrappers — `chat search`, `chat group rename`, `chat group members list/add/remove/add-bot`, `chat bot search` — are now produced by the envelope as dynamic commands. Net diff in the helper: `+358 / -71` overall (re-aligning to envelope-owned chat structure), and `chat_test.go` drops 71 lines of test-stubs the dynamic path covers natively. Every previously documented chat command keeps the same flag set and the same MCP tool routing — the surface is just sourced differently.
|
||||
- **Pipeline executor fail-fast on `content.errorCode`** (#317, see `internal/compat/pipeline.go`) — when an upstream tool returns a non-empty `content.errorCode`, `executePipelineCall` raises a validation error immediately with the upstream `errorMessage` instead of proceeding into the poll/download phase. Pre-execution cobra validation (`MarkFlagRequired`) only checks that a flag was set, not that its value was non-empty — so a `--required-flag ""` reaches the upstream tool and the upstream rejects with `errorCode`. Without the short-circuit the pipeline kept polling for a task ID that would never exist, either spinning to `PollTimeout` or burning through retries with no actionable error. Exit code 2 (validation), same as any other CLI-layer pre-flight rejection.
|
||||
- **Service-discovery dedup keys now include `cli.id`** (#317, see `internal/market/registry.go`) — `NormalizeServers` used to dedup envelope entries by endpoint alone (and by `displayName` in the second pass), which collapsed envelope entries that intentionally split one MCP endpoint into multiple CLI command trees. The `bot-root` / `bot-message` / `bot-group` trio all front the same `.../server/4717...` MCP endpoint and share the displayName `机器人消息`, but each declares a distinct `cli.id` and a distinct CLI command root; the old dedup kept only the last-write and dropped two of them. The dedup key now appends `#<cli.id>` when present, falling back to endpoint / name when absent so historical envelopes without `cli.id` keep their existing behaviour. Coverage in `internal/market/registry_test.go`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`@<text>` injection no longer eats Chinese mentions like `@所有人` / `@张三`** (#317, see `internal/cli/stdin.go`) — `ReadFileArg` and `ResolveInputSource` used to treat *any* value starting with `@` as the `@<filename>` injection syntax. Chat-bot webhook payloads commonly contain literal mentions, so `dws chat message send-by-bot --text "@所有人 周报"` was failing with `file not found: 所有人 周报` before the message reached the API. The new `looksLikeFilePath` heuristic accepts `@` followed by an ASCII path-prefix character (`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`), or `@-` for stdin, and passes the value through unchanged otherwise. `@A 但接下来都是中文@测试` *does* still attempt a file lookup because the rune right after `@` is ASCII — this matches the documented `@<path>` prefix shape. The historical "bare `@` is an error" behaviour is preserved. Coverage in `internal/cli/stdin_test.go::TestReadFileArgChineseAtMention`.
|
||||
- **`dws chat` no longer nests as `dws chat chat` when two envelope servers contribute the same top-level command** (#317, see `internal/compat/dynamic_commands.go`) — `BuildDynamicCommands` used to overwrite `topLevel[name]` on the second contribution and rely on `attachOrMerge` later, which then attached the *whole* incoming command (named `chat`) under the existing root, producing `dws chat chat <leaf>`. The new `mergeSubcommandsInto` moves the second contribution's *children* under the first root and drops the duplicate wrapper, so e.g. `group-chat` + `im` envelopes that both declare `cli.command: chat` produce a single flat `dws chat` subtree.
|
||||
- **Multi-server tool-name authority correction in the runtime runner** (#317, see `internal/app/runner.go` + `internal/app/direct_runtime.go`) — when two envelope servers share the same `cli.command`, the per-product endpoint map `endpoints[cmd]` in `registerDynamicServer` is second-writer-wins, and `catalog.FindProduct` may return the wrong server's endpoint for a tool whose real owner is the *other* server. `runtimeRunner.Run` now cross-checks the canonical tool→endpoint map exposed by the new `directRuntimeToolEndpoint`: when the per-tool endpoint exists and differs from the per-product endpoint the catalog returned, the tool-owner endpoint wins. Pairs with the registry dedup change above so the routing matches the dedup result.
|
||||
|
||||
## [1.0.29] - 2026-05-17
|
||||
|
||||
Three discovery-envelope products land on the open-source surface — `aiapp` (AI applications), `live` (DingTalk live streaming), and `aisearch` (enterprise people search) — closing the gap with the Wukong edition's product list. The `aisearch` envelope ships rich model-tolerance affordances (short flags, flag aliases, subcommand aliases) so AI agents that hallucinate keyword synonyms (`--query` / `--name` / `--q` / `--text` / `--find`) or alias subcommands (`search` / `find` / `query` / `user` / `people` / ...) still route to the canonical `person` tool instead of erroring out. To support that final fragment of agent tolerance, `internal/compat/registry.go` relaxes the envelope-generated leaf command's `Args` validator from `cobra.NoArgs` to `cobra.ArbitraryArgs` — restoring cobra's own default (`legacyArgs` returns nil for leaves) so trailing positional words are silently ignored. Plus the previously-shipped credential-isolation fix.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws aiapp` / `dws live` / `dws aisearch` — three new products discovered via envelope** (no public issue; pre-Diamond rollout) — open-source `dws` now exposes:
|
||||
- **`dws aiapp`** — AI application lifecycle: `create --prompt <p> [--attachments <json>] [--skills <csv>]` / `query --task-id <id>` / `modify --prompt <p> --thread-id <id> [--skills <csv>]`. Backed by upstream `create_ai_app` / `query_ai_app` / `modify_ai_app` MCP tools.
|
||||
- **`dws live stream list`** — list my DingTalk live streams. Backed by upstream `get_my_lives`.
|
||||
- **`dws aisearch person`** — enterprise people search by keyword + multi-dimension filter. Dimensions: `all` (default) / `name` / `department` / `position` / `duty` / `supervisor` / `subordinate` / `phone` / `jobNumber` — multiple comma-separated (`--dimension name,department`). Backed by upstream `enterprise_person_search`.
|
||||
- The `aisearch` envelope additionally registers `-w` / `-d` short flags (keyword / dimension); hidden flag aliases `--query` / `--name` / `--q` / `--text` / `--find` all routing to `keyword`; and cobra subcommand aliases `search` / `find` / `query` / `user` / `people` / `search-person` / `search-user` / `user-search` / `lookup` / `ask` / `contact` all routing to `person`. This closes the F-class model-tolerance regression cases in `dws-wukong/auto-test/cli_to_mcp/testcases/aisearch/test_90_aisearch_param_regression.py` (50/50 pass for aiapp + live + aisearch on the pre-mcp build).
|
||||
- **Users must run `dws cache refresh` once** to pick up the new envelopes; no binary upgrade is required, but pairs naturally with the v1.0.29 client (see Fixed below for the envelope-leaf-Args change).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Envelope-generated leaf commands now tolerate trailing positional args** (#306, no public issue) — `NewDirectCommand` in `internal/compat/registry.go` was hard-coding `cobra.NoArgs` for leaves without positional bindings (`totalMax == 0`). This is stricter than cobra's own `legacyArgs` (cobra `args.go:30-32` returns `nil` for any command without subcommands), and surfaced as `unknown command "<word>" for "<leaf>"` whenever an AI agent passed trailing positional words after a leaf — e.g. `dws aisearch person search --keyword "张"` or `dws aisearch person user search --keyword "张"`. Switching the `totalMax == 0` branch (and the initial value) from `cobra.NoArgs` to `cobra.ArbitraryArgs` restores cobra's natural leaf behavior: trailing positional args are silently ignored. Existing positional-binding paths (`MinimumNArgs` / `RangeArgs` / `MaximumNArgs`) are unchanged. Verified against `dws-wukong/auto-test/cli_to_mcp/testcases` — aiapp (9/9) + live (3/3) + aisearch (38/38) = **50/50** pass, vs 48/50 before this patch.
|
||||
|
||||
### Security
|
||||
|
||||
- **App credential files are partitioned by edition to prevent cross-edition credential leakage** (#300, no public issue; found during internal review) — different `dws` editions sharing the same config directory previously read and wrote the same `app.json`. A sibling edition that pinned its OAuth client ID could persist that ID through the shared post-login path, and the open-source build could later adopt it from the same file. Open-source/empty edition keeps the legacy `app.json` path for compatibility; sibling editions now use `app-<edition>.json`, matching the existing cache partitioning strategy. This prevents new cross-edition app credential writes and reads from colliding. After a sibling edition saves its new partitioned file, it also best-effort removes a legacy `~/.dws/app.json` only when that file's `clientId` matches the sibling edition being saved; a different, unparsable, or otherwise unowned `app.json` is left untouched to avoid deleting open-source credentials. If you previously ran multiple editions in one shared `~/.dws`, remove any confirmed-stale orphan manually with `rm ~/.dws/app.json` after verifying it is not the open-source credential file you still need.
|
||||
|
||||
## [1.0.28] - 2026-05-14
|
||||
|
||||
A single symmetric follow-up to 1.0.26's #250: `dws chat message send --group <cid>` now refuses an empty `--title` at the CLI layer instead of letting the call fall through to the API and surface a misleading `发群服务窗会话消息失败` error. No other behaviour changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws chat message send` rejects missing `--title` on group messages** (#294, completes #250) — `send_message_as_user`'s schema marks `title` as required (just like `send_direct_message_as_user`), but `buildChatMessageSendInvocation` only had the pre-validation on the direct-message branches. Group sends without a title were falling through to the API and returning the same misleading `发群服务窗会话消息失败` that #250 already fixed for direct messages. The check now covers both branches: missing `--title` on `--group` returns `--title is required for group messages (--group)` with exit code 2; missing on `--user` / `--open-dingtalk-id` keeps the original `--title is required for direct messages (--user / --open-dingtalk-id)`. The `Long` help, `--title` flag description, the first `Example`, and `skills/references/products/chat.md` (including the drive→chat workflow example) are realigned to "title is required for both direct and group messages" — the docs previously contradicted themselves (the prose said 群聊可选 while the flag listing said 必填). `internal/helpers/chat_test.go` adds a `group-without-title` rejection case; the existing `group` / `positional-text` success cases now pass `--title` to stay aligned with the new validation. No API request shape change — the server has always required `title`; the CLI now matches.
|
||||
|
||||
## [1.0.27] - 2026-05-14
|
||||
|
||||
Two user-visible fixes plus the schema primitive they're built on. `dws doc update` now reads Markdown from a file or stdin, so long / multi-line / table-heavy content no longer gets mangled by shell escaping; `dws sheet find --query` stops returning `unknown flag` on the open-source build, restoring copy-paste from internal wukong docs. Underneath, schema/discovery envelopes get a generic `file_read` transform and a `CLIFlagOverride.MapsTo` field that lets two sibling CLI flags route into the same MCP parameter slot. Also suppresses a noisy WARN on normal stdio-plugin shutdown.
|
||||
|
||||
### Added
|
||||
|
||||
- **`file_read` transform + `CLIFlagOverride.MapsTo` field** (#291, closes #277 #278 #282 #288) — discovery envelopes can now declare a path-typed CLI flag that performs the "file path → file contents string" conversion client-side before the value reaches the upstream MCP parameter.
|
||||
- `transform: "file_read"` (`internal/compat/transform.go`) — reads the file at the flag's value with UTF-8 validation; `-` means stdin. Any IO / encoding failure is surfaced as a validation error (exit 2), distinct from the generic transient-failure path (exit 1).
|
||||
- `CLIFlagOverride.MapsTo` (`internal/market/registry.go`) — redirects the flag's final value (post-transform or literal) into a named MCP parameter slot instead of the default `params[propertyName]`. This lets a single MCP parameter (e.g. `markdown`) be fed by two sibling CLI flags — a literal `--content` and a file-reading `--content-file` — paired with the existing tool-level `MutuallyExclusive` / `RequireOneOf` to express "exclusive, at least one".
|
||||
- Wired into the `internal/compat/dynamic_commands.go` normalizer via a separate `mapsToRoutes` collection + routing pass; empty `MapsTo` preserves the legacy `params[propertyName] = value` semantics, so every pre-existing dynamic_commands test passes unchanged. Pre-prod end-to-end verified across 6 cases (see PR #291's Validation table).
|
||||
- **`dws doc update --content-file <path>` (envelope rollout)** — fixes "long Markdown can't reach the doc". The old command only accepted `--content "..."`, so long / multi-line / table-heavy Markdown got mangled by shell escaping and AI agents writing >2KB of content were stuck. The envelope now maps both `--content` (literal) and `--content-file` (`file_read` transform) to the `markdown` parameter, makes them mutually exclusive via cobra's `MarkFlagsMutuallyExclusive`, and requires at least one via `RequireOneOf`. `--content-file -` reads from stdin, so `cat long.md | dws doc update --content-file -` works directly. **Existing users must run `dws cache refresh` once** to pick up the new envelope.
|
||||
- **`dws sheet find --query` hidden alias (envelope rollout)** — fixes "unknown flag when copy-pasting commands across editions". Users copying `dws sheet find --query "..."` from internal wukong docs onto open-source `dws` got `unknown flag: --query`, because the open-source primary flag is named `--find`. The envelope now registers `--query` as a hidden alias of `--find` via `CLIFlagOverride.Aliases` (the field shipped in 1.0.26) — it doesn't show up in `--help`, but accepts values and writes to the same MCP parameter. `--find` behaviour is unchanged. Also requires `dws cache refresh` once.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Noisy `failed to stop stdio client: exit status 1` WARN on normal stdio-plugin shutdown** (#285) — when `Stop()` explicitly `Kill`s the subprocess, the non-zero exit code returned by `cmd.Wait()` is expected behaviour, but it was being propagated as an error and logged to stderr on every CLI exit, polluting agent log parsing. `Stop()` now returns `nil` after Kill + Wait; the error path is reserved for "process exited on its own with non-zero" (e.g. stdin close without an explicit Kill). `internal/transport/stdio.go` + `stdio_integration_test.go` assert "Stop() returns nil after kill".
|
||||
|
||||
## [1.0.26] - 2026-05-12
|
||||
|
||||
Platform-stability round: Windows PAT-auth browser opener no longer truncates URLs at `&userCode=`, macOS sandbox hosts get an opt-in keychain fallback, and `dws doc download` rejects `axls` nodes before requesting `drive:download` consent. Two new global output formats `-f ndjson` and `-f csv` (matching `larksuite/cli`) land as first-class citizens with real-traffic-verified list detection. The `dws doc comment *` regression tracked in #240 is also resolved — fix is in the market metadata, users just need `dws cache refresh` once.
|
||||
|
||||
### Added
|
||||
|
||||
- **`-f ndjson` and `-f csv` global output formats** (#259, closes #252) — `ndjson` emits one compact JSON record per line (works straight with `jq -c` / `while read` / log pipelines); `csv` goes through `encoding/csv` (RFC-4180 — quoting, embedded newlines, CJK all handled by stdlib) and reuses the existing `-f table` column resolver (`normalizePayload` / `unwrapPrimaryObject` / `extractRowsFromMap` / `rowsFromSlice` / `formatValue`) so table and csv stay visually aligned. After a 7-product real-traffic sweep (contact / chat / doc / mail / todo / minutes / schema), the `preferredListKeys` whitelist was extended to cover the actual DingTalk envelope shapes — `contact user search` (`result`), `chat search` (`result.value`), `doc search` (`documents`), `mail mailbox list` (`emailAccounts`), `todo task list` (`result.todoCards`) — so these commands now degrade into a proper row stream instead of collapsing to a single-line `key,value` blob. Lives in `internal/output/ndjson.go` + `internal/output/csv.go`; `--format` help in `internal/app/flags.go` now lists `ndjson|csv` alongside `json|table|raw|pretty`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Sticky flag splitting is now schema-aware** (#272) — PreParse `StickyHandler` 此前会把任何前缀命中已知 flag 的 `--flagsuffix` 一律切成 `--flag suffix`,于是 `--starttime20260507` 这类拼错被静默改写成 `--start time20260507`,把假值传到下游。新行为按 flag 的 pflag 类型 / JSON Schema `format` / `enum` 校验 suffix 是否像合法 value(共享逻辑见 `pkg/cmdutil/sticky_suffix.go`),不像就保留原 token 让 cobra 报 `unknown flag`。slice/array/object 类型的 flag 永不切分。首 rune 读取使用 `utf8.DecodeRuneInString`,对中文等多字节 value 安全。
|
||||
|
||||
### Added
|
||||
|
||||
- **`available_flags` field on unknown-flag errors** (#272) — `dws -f json` 的 unknown-flag 错误体里新增 `available_flags`(已排序、过滤掉 hidden 与内部 `json` / `params`),方便 agent 不解析 `--help` 就能恢复。Human-readable 输出会附 `Flags: ...` 行,截断在 200 字节内。
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws chat message send` 单聊缺 `--title` 时前置校验** (#250) — 单聊(`--user` / `--open-dingtalk-id`)的底层工具 `send_direct_message_as_user` 在 API 层强制要求 title,缺失时返回误导性的 `发群服务窗会话消息失败`。CLI 现在在 `buildChatMessageSendInvocation` 里前置校验,直接返回 `--title is required for direct messages (--user / --open-dingtalk-id)`;同时把 `Long` help、`--title` flag 描述、Example 和 `skills/references/products/chat.md` 全部对齐为「单聊必填,群聊可选」。群聊行为不变。
|
||||
- **PAT auth URLs were truncated on Windows browser open** (#242, fixes #230) — `cmd /c start <url>` on Windows interprets `&` as a command separator, so PAT URLs containing `&userCode=...` were silently chopped before the userCode segment, and the browser landed on a 0-permission DingTalk page. The retry opener now uses `rundll32 url.dll,FileProtocolHandler`, which passes the URL through verbatim. The PAT response also exposes a copy-safe `data.authorizationUrl` (in addition to the service-provided `data.uri`, which is preserved as-is), and human-readable PAT output prints `PAT_AUTHORIZATION_URL=<full-url>` on its own line so OpenClaw-style host wrappers that swallow or reformat stderr can still capture the full link. Legacy DingTalk hash-route shapes (`https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId=...%26userCode=...`) are normalised back into the working `/fe/old?hash=...#/personalAuthorization?...&userCode=...` form. Regression tests cover the issue-shaped URLs (encoded hash, fragment, `&userCode`) plus the OpenClaw malformed-hash variant.
|
||||
- **`dws doc download` triggered `drive:download` PAT consent for unsupported axls nodes** (#268, fixes #190) — added a `get_document_info` preflight before `download_file`, so online-sheet (`axls`) nodes are rejected locally with guidance to use sheet range tools instead. The preflight reads `extension` from deterministic response paths (no recursive payload scan) and routes its own PAT errors back through `handlePatAuthCheck`, preserving device-flow / host-owned PAT behaviour. Costs one extra MCP roundtrip per `doc download` — deliberate, so the unsupported path fails before consent. Lives in `internal/app/doc_download_preflight.go`; coverage in `internal/app/runner_test.go`.
|
||||
- **macOS sandbox hosts (Codex App etc.) couldn't read/write tokens via Keychain** (#267, fixes #214) — sandboxed macOS environments intercept `security` / Keychain APIs, so every token operation failed. New opt-in `DWS_DISABLE_KEYCHAIN=1` switches macOS to the same file-DEK path Linux uses (DEK at `~/Library/Application Support/dws-cli/dek`, mode `0600`), bypassing the system Keychain. Default behaviour is unchanged — fallback is strictly opt-in because file-DEK is a weaker trust model than Keychain-managed storage (DEK file sits next to ciphertext in the same directory). The Darwin / Linux file-DEK implementation is now shared in `internal/keychain/file_dek.go` (Linux path deduplicated by ~40 lines). Documented in `docs/reference.md` (中英) with the security tradeoff spelt out so users make the choice explicitly.
|
||||
- **`dws doc comment {list,create,create-inline,reply}` returned `PARAM_ERROR - 未找到指定工具`** (fixes #240, also #234) — the four comment tools used to live on an independent `doc-comment` MCP server. After the Portal merged comment functionality into the `doc` server descriptor, the runtime `tools/list` on the merged `doc` server didn't include them, so every `dws doc comment *` call returned the "tool not found" PARAM_ERROR. The market metadata for the `doc` server now declares `serverOverride: "doc-comment"` on all four comment `toolOverrides`, so the existing CLI routing path sends `dws doc comment *` to the still-running `doc-comment` MCP server (which has the tools). No CLI code change was required, but **existing users must run `dws cache refresh` once** to pick up the updated descriptor — without that, the stale local market cache keeps pointing the call at the merged `doc` server and the error persists. Verified post-refresh: dry-run resolves to `https://mcp-gw.dingtalk.com/server/doc-comment` with tool `list_comments`, real calls return normal business responses (e.g. legitimate cross-org authz errors) instead of `未找到指定工具`.
|
||||
|
||||
## [1.0.25] - 2026-05-11
|
||||
|
||||
Two generic envelope-schema enhancements that close gaps the `cli_to_mcp` test suite kept surfacing — both product-agnostic, no hardcoded helper commands. Plus missing skill references for the already-registered `sheet` and `wiki` products are now shipped.
|
||||
|
||||
### Added
|
||||
|
||||
- **`sheet` (在线电子表格) skill reference + product-overview entry** — the `sheet` product registers **34 envelope tools** covering worksheet CRUD (`create` / `new` / `list` / `info` / `copy_sheet` / `update_sheet`), range read/write (`range read` / `range update` / `append`), dimension ops (`add-dimension` / `insert-dimension` / `delete-dimension` / `move-dimension` / `update-dimension`), merge (`merge-cells` / `unmerge-cells`), find/replace (`find` / `replace`), filter views (`filter-view {create, list, update, delete, update-criteria, delete-criteria}`), sheet-level filters (`create_filter` / `get_filter` / `update_filter` / `delete_filter` / `set_filter_criteria` / `clear_filter_criteria` / `sort_filter`), image write (`write-image`), and async export (`submit_export_job` + `query_export_job`). These were live in the envelope but `skills/references/products/sheet.md` had not shipped and `skills/SKILL.md` 产品总览 didn't list `sheet`, so agents had no reference to consult and were skipping it during intent routing. This release adds the doc, registers `sheet` in 产品总览 + 意图判断决策树, extends `description` to include 在线电子表格, adds a Sheet row to `README.md` / `README_zh.md` "Key Services", and notes the v1.0.25 reality on naming (about a third of `sheet` tools still expose snake_case cli_names pending `CLIAliases` (#246) rollout) and on export (no consolidated `dws sheet export` exists in v1.0.25 — `submit_export_job` + `query_export_job` are the atomic primitives; Pipeline (#247) provides the future plumbing).
|
||||
- **`wiki` (知识库) skill reference + product-overview entry** — the wiki product's 7 envelope tools (`wiki.create_wikiSpace`, `wiki.get_wikiSpace`, `wiki.list_wikiSpaces`, `wiki.search_wikiSpaces`, `wiki.add_member`, `wiki.list_member`, `wiki.update_member`, surfaced as `dws wiki space create / get / list / search` and `dws wiki member add / list / update`) have been registered for a while, but no `skills/references/products/wiki.md` shipped with them, so agents had no per-command reference to consult. This release adds the reference doc, registers `wiki` in `skills/SKILL.md`'s 产品总览 table and 意图判断决策树, mentions 知识库 in the skill `description` frontmatter, adds a Wiki row to `README.md` / `README_zh.md` "Key Services", and removes `wiki` from the "Coming soon" callout (which was now stale).
|
||||
- **`CLIToolOverride.CLIAliases` envelope field** (#246) — lets a single MCP tool register additional cobra command aliases via envelope JSON (e.g. `range read` also accepts `range get`, `member list` accepts `member ls`). Plumbed through the existing `Route.Aliases → cobra.Command.Aliases` path; sibling conflicts are silently dropped by cobra. Lives in `internal/market/registry.go` + `internal/compat/dynamic_commands.go`.
|
||||
- **`json_parse_strict` transform** (#246) — strict-JSON variant of `json_parse` that does **not** fall back to YAML. Use when the upstream tool requires a structured array/object and silently coercing a malformed input to a scalar string would mask a real user error (observed: `filter-view --criteria 'NOT_VALID_JSON'` was being accepted and quietly creating an empty-criteria view). In `internal/compat/transform.go`.
|
||||
- **`CLIToolOverride.Pipeline` + pipeline executor** (#247) — a single CLI command can now orchestrate an ordered sequence of MCP tool calls plus optional HTTP-download sinks, declared entirely in envelope JSON. Motivating use case: the "submit-job → poll-status → download-result" pattern (e.g. sheet export) that previously required per-product hardcoded helpers.
|
||||
- `PipelineStep` supports `type:"call"` (with optional `PollUntilField` / `PollUntilValue` / `PollIntervalSec` / `PollTimeoutSec` for polling loops) and `type:"download"` (resolves `DownloadURLField`, HTTP GETs the body, writes to the path from `OutputFlag`, infers filename for directory paths).
|
||||
- Template language: `$flag.<name>` resolves a user CLI flag by alias; `$step.<idx>.<dotPath>` walks a prior step's response (works through wrapped MCP envelopes); literals pass through.
|
||||
- `CLIFlagOverride.PipelineLocal` marks a flag as CLI-side only so `CollectBindings` skips it (value never reaches MCP params); the pipeline executor still reads it via `extractFlagValuesByAlias`.
|
||||
- Download step emits machine-parseable plain-text lines (`jobId: <id>\n`, `downloadUrl: <url>\n`) alongside the standard JSON envelope, so shell pipelines and regex-based tests can extract key values without JSON parsing.
|
||||
|
||||
## [1.0.24] - 2026-05-09
|
||||
|
||||
Three small but user-visible safety/usability changes: the embedded distribution now refuses to self-upgrade, the `dws auth login` help text finally matches the actual default flow (loopback, not device), and the release workflow gains a manual fallback trigger.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`dws upgrade` is blocked in embedded distributions** (#248) — when the CLI is shipped as an embedded asset (e.g. inside another product), `dws upgrade` would happily overwrite the host-managed binary. The upgrade entry point now detects the embedded build flag and exits early with a clear message; covered by `internal/app/upgrade_embedded_guard_test.go`.
|
||||
|
||||
### Docs
|
||||
|
||||
- **`dws auth login` help text reflects the real default** (#238, fixes #226) — the long help previously claimed "OAuth 设备流 (默认)", but the actual default starts a 127.0.0.1 loopback listener and only switches to device flow when `--device` is passed. SSH-into-headless-Linux users following the old text hit a dead end (remote-side 127.0.0.1 is unreachable from the local browser). Help and two `flagErrorWithSuggestions` messages in `root.go` are realigned: each method is named after its real flag (`OAuth Loopback 流 (默认)` / `OAuth 设备流 (--device)` / `直接提供 Token (--token)`), with an explicit `--device` example for SSH/headless. No behaviour change.
|
||||
|
||||
### CI
|
||||
|
||||
- **`workflow_dispatch` trigger added to release workflow as a fallback** (#261) — GitHub occasionally drops tag-push events; the release job can now be re-run manually against any tag ref without having to delete and re-push the tag.
|
||||
|
||||
## [1.0.23] - 2026-05-08
|
||||
|
||||
A single fix for HTTP proxy support across the CLI's custom HTTP transports. No behaviour changes elsewhere.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`HTTP_PROXY` / `HTTPS_PROXY` environment variables silently ignored by all custom transports** (#237, fixes #236) — the three custom `http.Transport` instances built by the CLI (`internal/transport/client.go` MCP transport, `internal/apiclient/client.go` DingTalk OpenAPI client, `internal/app/legacy.go` IPv4-forcing registry client) all set `DialContext` / `TLSClientConfig` / timeouts but omitted the `Proxy` field. Per Go's `net/http` contract, a non-nil Transport without an explicit `Proxy` means "no proxy" — env vars are silently ignored, breaking sandboxed or air-gapped deployments that route outbound through `HTTP_PROXY` / `HTTPS_PROXY`. All three transports now set `Proxy: http.ProxyFromEnvironment`.
|
||||
|
||||
### Tests
|
||||
|
||||
- Per-package regression test that pointer-compares the Transport's `Proxy` func against `http.ProxyFromEnvironment`, avoiding flakiness from Go's `envProxyOnce` memoisation when running alongside tests that read proxy env early. (#237)
|
||||
|
||||
## [1.0.22] - 2026-05-07
|
||||
|
||||
Two release-blocking bug fixes: `dws attendance summary` now exposes the server-required `--stats-type` flag (without it, every call returned C0002), and the install scripts finally populate `~/.hermes/skills/dws/` for users who already have Hermes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws attendance summary` returned C0002 (统计类型错误) on every call** (#228, fixes #227) — the DingTalk MCP tool `get_attendance_summary` requires `statsType` at the business layer even though the schema marks it optional. The CLI did not expose any way to set it, so the command was 100% unusable. A new `--stats-type` flag (`week` / `month`) is now plumbed through to `QueryUserAttendVO.statsType`; the flag is documented as required in the long help, flag description, and `skills/references/products/attendance.md`.
|
||||
- **Install scripts skipped `.hermes/skills/` when populating skill directories** (#221, fixes #188) — the `AGENT_DIRS` lists across `build/npm/install.js`, `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh` and the four upgrade-path mirrors (8 sources total once review feedback was addressed) did not include `.hermes/skills`, so users with Hermes installed were not getting `~/.hermes/skills/dws/` populated automatically. The existing parent-directory gate keeps this zero-side-effect for users without Hermes.
|
||||
|
||||
### Tests
|
||||
|
||||
- New `--stats-type` regression coverage in `test/cli_compat/attendance_test.go` — verifies `statsType` is written to `QueryUserAttendVO` when set to `month` or `week`, and is omitted when not provided. (#228)
|
||||
|
||||
## [1.0.21] - 2026-05-05
|
||||
|
||||
A single critical routing fix for `dws drive` commands. No new commands or behaviour changes elsewhere.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws drive mkdir` / `dws drive download` silently routed to the doc MCP server** (#220, fixes #219) — when two MCP servers register tools with the same name (e.g. both `drive` and `doc` expose `create_folder`), the tool-level endpoint map used last-writer-wins, so drive-side calls landed on the doc endpoint and returned mock-shaped responses (`success: true` with a fake `folderId`) without actually creating anything. `directRuntimeEndpoint` now resolves product-level first when the caller already knows the productID, and only falls back to the tool-level lookup when productID is empty. The wrong-server collision and the resulting "succeeded but didn't" behaviour are gone.
|
||||
|
||||
## [1.0.20] - 2026-05-04
|
||||
|
||||
Documentation polish and a login regression fix. No behaviour changes outside the login MCP refresh path.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Login no longer reuses stale `clientId` from an old MCP cache** (#213) — `dws login` now unconditionally re-fetches the MCP descriptor, so a previously cached client id can't keep producing auth errors after the server rotates it.
|
||||
|
||||
### Docs
|
||||
|
||||
- **`dws chat message list` pagination** (#218, fixes #195) — clarifies that `nextCursor` is opaque and must be passed back as `--cursor` exactly; warns against parsing or reusing it as an offset.
|
||||
- **`dws contact search` examples** (#209) — switched from the removed `--keyword` flag to the current `--query`.
|
||||
- **`dws todo` help text** (#205) — expanded field semantics so MCP wrappers generate accurate schemas.
|
||||
- **`dws chat message send-by-bot` and `dws report create` help** (#217, #106, #107) — `--robot-code` / `--title` / `--text` now carry the `(必填)` marker; `report create --contents` documents the `key=field_name` requirement and rewrites examples as a `template detail → create` two-step pipeline.
|
||||
- **CHANGELOG backfill for 1.0.19** (#204).
|
||||
|
||||
## [1.0.19] - 2026-04-30
|
||||
|
||||
Discovery hardening for edition overlays: `edition.SupplementServers` / `FallbackServers` hooks now consistently surface through the **runtime catalog loader**, not just the static command tree, so overlay products that live outside the Portal envelope (e.g. Wukong gray-release `conference`) resolve an endpoint on both the cold-cache and tool-not-in-catalog paths. Ships with per-edition cache partitioning to stop cross-edition disk-cache leakage, plus a small todo fix.
|
||||
|
||||
### Added
|
||||
|
||||
- **`pkg/config.EditionPartition(name)`** (#197) — returns the cache partition key for a given edition. Open-source core (`""` / `"open"`) keeps using `DefaultPartition` (`default/default`); every other edition gets its own namespace (`<edition>/default`), preventing cross-edition data leakage in the shared `~/.dws` disk cache. Lives in `pkg/config` as a leaf helper so `internal/cli`, `internal/app`, and `internal/cache` can all call it without risking import cycles.
|
||||
- **`internal/editionmerge` shared package** (#197) — single source of truth for converting `edition.ServerInfo` into `market.ServerDescriptor` (`ToDescriptor`) and for merging `SupplementServers` / `FallbackServers` into a descriptor list. Both `internal/cli` (command tree) and `internal/app` (runtime catalog) now apply the edition hooks against the same discovery pipeline.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`EnvironmentLoader.loadFromCache` honors `SupplementServers` even on empty registry** (#197) — when the Portal registry cache is missing or empty, the catalog loader still materialises the edition's `SupplementServers` as endpoint-only `discovery.RuntimeServer` entries (source: `edition_supplement`), so hardcoded overlay commands for supplement-only products can still resolve an endpoint via the catalog path. Previously `loadFromCache` short-circuited to an empty catalog whenever the registry snapshot was empty, silently dropping gray-release products.
|
||||
- **Cache loader switches from `DefaultPartition` to `EditionPartition(edition.Get().Name)`** (#197) — the runtime catalog, registry snapshot, and tools snapshot are now partitioned per edition instead of all editions sharing `default/default`.
|
||||
- **`loadFromCache` appends supplement servers alongside fresh-cache servers** (#197) — supplement entries whose `CLI.ID` / `Key` are already present in the cached registry are skipped, so the hook never shadows Portal-published servers; only new products are added.
|
||||
- **`runtimeRunner.Run` falls through to `directRuntimeEndpoint` for supplement products** (#197) — when the catalog contains the product (e.g. supplied by `SupplementServers`) but the specific tool is not declared, the runner now trusts `directRuntimeEndpoint` to resolve a working endpoint for the tool before returning the explicit catalog-miss error. Supplement entries intentionally carry no tool list, so this is the path that makes overlay-only tools executable.
|
||||
- **Legacy `mergeSupplementServers` / `fallbackToDescriptors` moved out of `internal/app/legacy.go`** (#197) — relocated into `internal/editionmerge` and reused by the catalog loader, eliminating the duplicate `edition.ServerInfo → market.ServerDescriptor` logic that previously only ran on the static command-tree path.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws todo task get` returns empty** (#202) — the helper was calling `query_todo_detail`, which is not a valid MCP tool and returns empty. Switched to `get_todo_detail` as declared in `discovery.json`, restoring correct task-detail behaviour.
|
||||
- **Conference and other Wukong gray-release products miss endpoint on cold cache** (#197) — products registered only via `edition.SupplementServers` (not yet in the Portal envelope) now resolve an endpoint through the catalog path in both cold-start and tool-not-declared scenarios.
|
||||
|
||||
### Tests
|
||||
|
||||
- `internal/editionmerge/merge_test.go` — descriptor conversion + supplement/fallback merge semantics.
|
||||
- `internal/cli/loader_partition_test.go` + `loader_supplement_test.go` — edition-partitioned cache reads and supplement hook surfacing from `loadFromCache` (including empty-registry cold path and existing-ID deduplication).
|
||||
- `internal/app/legacy_wukong_partition_e2e_test.go` — end-to-end cache partition isolation for the Wukong edition.
|
||||
- `internal/app/runner_supplement_fallback_test.go` — runner falls through to `directRuntimeEndpoint` when the tool isn't declared by a supplement-sourced catalog entry.
|
||||
- `pkg/config/constants_test.go` — `EditionPartition` name handling (`""`, `"open"`, custom edition).
|
||||
|
||||
### Docs
|
||||
|
||||
- **CHANGELOG v1.0.18 rewrite** (#193) — previous release notes expanded to call out the PAT host-owned A-core flow, exit-code contract change (auth `4`, Discovery/cache/protocol `6`), `dws pat chmod` / `pat browser-policy` entry points, stderr-JSON classifier updates, and host-control metadata injection.
|
||||
|
||||
## [1.0.18] - 2026-04-28
|
||||
|
||||
Raw DingTalk OpenAPI access lands as a new `dws api` surface for both `api.dingtalk.com` and `oapi.dingtalk.com`, backed by app-level token caching and guarded host allowlists. PAT enters the host-owned **A-core** loop: agent hosts can own authorization UI through `DINGTALK_DWS_AGENTCODE`, parse single-line stderr JSON, call `dws pat chmod`, and replay the original command. Chat helper regressions are fixed, skill references are brought back in line with shipped commands, and the v1.0.17 Mail release notes are backfilled into README / CHANGELOG.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **PAT exit-code contract** (#142) — PAT authorization interceptions now use exit code `4`; Discovery, cache, and protocol negotiation failures now use exit code `6`. Downstream scripts that previously treated `4` as Discovery must update their handling.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws api` raw DingTalk OpenAPI command** (#184) — direct DingTalk OpenAPI calls without writing an MCP wrapper first. Supports `GET` / `POST` / `PUT` / `PATCH` / `DELETE`, JSON `--params` / `--data`, stdin input, dry-run previews, `--jq`, field selection, `--page-all`, `--page-limit`, `--page-delay`, and `--base-url`.
|
||||
- **Dual-form OpenAPI routing** (#184) — `api.dingtalk.com` requests use the `x-acs-dingtalk-access-token` header; `oapi.dingtalk.com` requests use the legacy `access_token` query parameter. The raw API client validates the target host before attaching credentials.
|
||||
- **App-level token cache for raw API** (#184) — custom-app credentials now fetch app access tokens from the unified OAuth endpoint, cache them while valid, and refresh them before expiry. The same token provider works for new-style and legacy OpenAPI calls.
|
||||
- **Host-owned PAT A-core flow** (#142) — when `DINGTALK_DWS_AGENTCODE` is set, PAT hits return `exit=4` plus single-line stderr JSON; the host renders authorization UI, calls `dws pat chmod <scope>...`, and replays the original command.
|
||||
- **`dws pat chmod` authorization entry point** (#142) — grants scopes with `--agentCode`, `--grant-type`, and session fallback support; `DINGTALK_DWS_AGENTCODE` can supply the agent code when the flag is omitted.
|
||||
- **PAT browser-open policy** (#142) — `dws pat browser-policy --enabled <true|false> [--agentCode <id>]` controls whether the CLI may open a browser, independently from `--format` output mode.
|
||||
|
||||
### Changed
|
||||
|
||||
- **README raw API guide** (#184) — English and Chinese READMEs now document custom-app prerequisites, api/oapi examples, auto-pagination, dry-run, jq filtering, security properties, and the new Raw API service-table row.
|
||||
- **Raw API token retrieval path** (#184) — token lookup now goes through a single app-token interface; stale auth-refresh retry helpers were removed from the raw API path.
|
||||
- **PAT stderr JSON classifier** (#142) — recognizes `code`, `errorCode`, and `error_code`, including `PAT_NO_PERMISSION`, risk-tier PAT errors, `PAT_SCOPE_AUTH_REQUIRED`, and `AGENT_CODE_NOT_EXISTS`.
|
||||
- **Host-control metadata injection** (#142) — classifier and active-retry paths now share one mutation point for `data.hostControl` and `data.openBrowser`, keeping host-facing JSON shapes aligned.
|
||||
- **Open-edition routing signals** (#142) — open edition pins `claw-type: openClaw`; `DINGTALK_AGENT`, `DWS_CHANNEL`, and host-owned PAT detection are kept as independent signals.
|
||||
- **Behavior authorization endpoint fallback** (#142) — the PAT runtime can resolve the built-in behavior-authorization MCP endpoint before discovery data is available.
|
||||
- **v1.0.17 documentation backfill** (#181) — the previous release notes and README service table now explicitly include the shipped Mail product, update the total to **163 commands across 14 products**, and remove Mail from "Coming soon".
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CLI auth-denial attribution** — local CLI authorization denials are attributed to the channel before falling back to user-scope classification, avoiding user-scope misclassification for channel-level auth failures.
|
||||
- **Opaque authorization URLs** (#182, #142) — PAT authorization links are preserved verbatim, including query/hash/fragment content required by the server.
|
||||
- **Polling compatibility** (#182, #142) — device-flow result envelopes and no-`flowId` device-code fallback remain supported, with guarded debug output and envelope priority.
|
||||
- **Group chat @-mentions restored** (#180) — `dws chat message send --group ...` again accepts and forwards `--at-users`, `--at-all`, and `--at-mobiles`; those flags are rejected outside group-chat mode so single-chat sends cannot silently drop @-mention intent.
|
||||
- **Explicit members-list command restored** (#180) — `dws chat group members list --id <openConversationId>` is reachable after the helper/dynamic merge path changed. `cmdutil.MergeHardcodedLeaves` now honors higher-priority helper groups when a dynamic envelope contributes a leaf at the same path.
|
||||
- **Skill reference command names** (#186) — `simple.md` now uses shipped OA command names (`list-pending`, `list-initiated`), removes a non-existent devdoc `search-error` command, and marks `workbench.md` as Draft because workbench commands are not available in the runtime.
|
||||
- **Empty grant result handling** (#142) — `dws pat chmod` now returns an explicit error instead of treating `{"Content": null}` as success.
|
||||
- **Session-id log safety** (#142) — raw `DWS_SESSION_ID` / `REWIND_SESSION_ID` values are no longer logged when the two env vars disagree.
|
||||
|
||||
### Tests
|
||||
|
||||
- Added raw API coverage for request validation, api/oapi routing, token management, pagination, response handling, dry-run output, JSON parsing, stdin handling, and command wiring. (#184)
|
||||
- Added chat/cmdutil regression tests for group @-mention forwarding, single-chat rejection, `members list`, helper-vs-envelope shape mismatch, and merge-priority behavior. (#180)
|
||||
- Added PAT contract coverage for host-owned signal selection, single-line stderr JSON, chmod env fallback and legacy alias fallback, browser policy, direct-runtime PAT endpoint fallback, and retry/poll behavior. (#142)
|
||||
- Coverage badge refreshed after the post-v1.0.17 CI runs.
|
||||
|
||||
## [1.0.17] - 2026-04-27
|
||||
|
||||
New **Mail** product surface (mailbox list, KQL message search, message get, send) brings runtime command count to **163 across 14 products**. Plugin command-tree visibility hardening: stdio plugins shipping CLI overlays no longer wait on subprocess discovery to surface their commands, and overlay-registered plugin products are no longer hidden by edition `VisibleProducts` whitelists. Chat docs clarify that `--title` is required on `dws chat message send`.
|
||||
|
||||
### Added
|
||||
|
||||
- **`mail` product** (#167) — new top-level service for DingTalk Mail. Four leaf commands across two subgroups:
|
||||
- `dws mail mailbox list` — list mailbox addresses available to the current user (`list_user_mailboxes`)
|
||||
- `dws mail message search` — KQL search across folders / sender / date / attachments / read-state (`search_emails`); supports `--cursor` pagination
|
||||
- `dws mail message get` — fetch full message body + headers + attachments by message ID (`get_email_by_message_id`)
|
||||
- `dws mail message send` — send email to one or more recipients (`send_email`)
|
||||
- Skill reference at `skills/references/products/mail.md` registered in `skills/SKILL.md` master index and intent decision tree
|
||||
- **Stdio plugin overlay-first command registration** (#179) — when a stdio plugin's `overlay.json` declares `toolOverrides`, command trees are built from manifest metadata synchronously at startup, no subprocess `Initialize` / `tools/list` handshake required. Previously, slow or failing subprocesses left plugin commands invisible in `dws --help`. Background discovery still runs to refresh the warm cache for richer flag types on subsequent startups.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`hideNonDirectRuntimeCommands` / `visibleMCPRootCommands` / `visibleUtilityRootCommands`** (#179) — refactored to share a single `resolveVisibleProducts()` helper that **unions** the edition's `VisibleProducts` hook with `DirectRuntimeProductIDs()`, so plugins registered via `AppendDynamicServer` stay visible in `dws --help` even when an edition installs a static product whitelist. Previously the hook fully replaced the dynamic registry, silently hiding plugin commands.
|
||||
- **`dws chat message send` documentation clarifies `--title` is required** (#174) — the helper command short text and the chat skill reference now state explicitly that `--title` is mandatory for both group and single-chat sends, matching the runtime validation.
|
||||
- **`buildStdioCommands` refactored to share helpers with the overlay-first path** (#179) — overlay parsing (`resolveStdioOverlay`) and tools→DetailTool conversion (`toolsToDetails`) extracted as package-level helpers; the legacy discovery-first stdio path now delegates to them, eliminating duplicated overlay JSON / cache-snapshot logic.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Negative-cache poisoning guard for stdio plugin discovery** (#179) — `refreshStdioToolsCache` now skips `SaveTools` entirely when discovery returns an empty tool list (transient failure, subprocess not ready, RPC timeout), so a single bad refresh cannot overwrite a previously-good cache and degrade flag enrichment on the next startup.
|
||||
|
||||
### Tests
|
||||
|
||||
- 6 new test cases in `internal/app/plugin_stdio_overlay_test.go` and `internal/app/visibility_test.go` cover overlay-first registration without discovery, warm-cache flag enrichment from `InputSchema`, fallback when overlays lack `toolOverrides`, the cache-poisoning guard, and integration cases for plugin visibility under restrictive `VisibleProducts` whitelists.
|
||||
- Coverage 49.8% → 52.8%.
|
||||
|
||||
## [1.0.16] - 2026-04-24
|
||||
|
||||
Discovery service abstraction with schema v3 extensions, open-edition helper-subtree restoration, and a defensive device-flow login reset.
|
||||
|
||||
### Added
|
||||
|
||||
- **`internal/discovery` service abstraction** (#156) — encapsulates market registry fetch, MCP runtime negotiation (`initialize → tools/list → detail` merge), and multi-level cache fallback. `EnvironmentLoader` now does cache-first startup, with degraded-mode reasons (`unauthenticated` / `market_unreachable` / `runtime_all_failed`) and `UpdatedAt`-based selective re-discovery.
|
||||
- **Schema v3 extensions** (#156) — positional parameters with typed coercion, `Example` on `--help`, flag `Default` / `RuntimeDefault` (with `$currentUserId` / `$now` etc.), `BodyWrapper`, `MutuallyExclusive` / `RequireOneOf` flag groups, `OmitWhen`, explicit `Type` override, and detail-schema `default` propagation.
|
||||
- **`dws chat message send` destination-flag routing** (#170) — open edition gains a hardcoded helper that dispatches by `--group` (→ `send_message_as_user`) vs `--user` / `--open-dingtalk-id` (→ `send_direct_message_as_user`), mirroring the closed-source overlay so single-chat sends finally work end-to-end.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`pickCommands` → `cmdutil.MergeHardcodedLeaves`** (#169) — when a top-level product name collides between the dynamic overlay and a helper subtree, helper-only siblings are grafted into the dynamic tree instead of dropped. Restores `dws chat message send-by-bot` / `recall-by-bot` / `send-by-webhook` and `dws chat group members add-bot`, which had silently vanished from the open edition.
|
||||
- **`OverridePriority` / `MergeHardcodedLeaves` promoted into `pkg/cmdutil`** (#170) — single source of truth for the merge layer; hardcoded leaves can opt into overriding the dynamic envelope via a strictly higher priority.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Device flow defensively resets credentials before login** (#157) — `--device` login now clears stale credential state and re-fetches `clientID` from the MCP server, regardless of what previous login methods (OAuth scan, PAT) left in `app.json`. Fixes the case where a prior OAuth login made `--device` fall back to direct mode and demand `clientSecret`.
|
||||
|
||||
## [1.0.15] - 2026-04-23
|
||||
|
||||
Compat layer gains **subcommand merging** under shared parents so multiple server entries can contribute into the same `dws <parent> <branch>` subtree without producing duplicate `--help` rows. Ships with a fresh auto-generated command index doc, a README sync to **159 commands across 13 products**, and a wide-ranging flag-naming cleanup that standardises CLI flags across chat, calendar, drive, minutes, contact, and devdoc commands.
|
||||
@@ -46,6 +345,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.
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
> [!IMPORTANT]
|
||||
> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Join the DingTalk DWS co-creation group for support and updates. See [Getting Started](#getting-started) below.
|
||||
>
|
||||
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
|
||||
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws Open Source Community DingTalk Group QR Code" width="150">
|
||||
|
||||
<details>
|
||||
<summary><strong>Table of Contents</strong></summary>
|
||||
@@ -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
|
||||
@@ -348,6 +394,8 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "Weekly Report" --text @-
|
||||
```
|
||||
|
||||
> **Note**: `@` is treated as the `@<path>` file-injection prefix only when the next character is an ASCII path-shaped character (`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`), or `@-` for stdin. Chat-bot payloads like `--text "@所有人 周报"` or `--text "@张三 看一下"` pass through unchanged, so literal mentions reach the API as-is.
|
||||
|
||||
</details>
|
||||
|
||||
## Key Services
|
||||
@@ -355,7 +403,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| Service | Command | Commands | Subcommands | Description |
|
||||
|---------|---------|:--------:|-------------|-------------|
|
||||
| Contact | `contact` | 6 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
|
||||
| Chat / IM | `chat` (alias `im`) | 23 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` | Messages (send / list / list-all / by-sender / mentions / focused / unread / topic replies / search), group CRUD + member management (incl. `add-bot`), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common groups lookup |
|
||||
| Chat / IM | `chat` (alias `im`) | 57 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | Messages (send / reply / list / list-all / by-sender / mentions / focused / unread / topic replies / search / advanced search / forward / cards / emoji & text-emotion reactions / recall / read & send status queries), group CRUD + member management (members add / remove / list / `add-bot`, member-role CRUD, invite URL, icon, settings, transfer-owner, set-admin, quit), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common-groups lookup, group/member/conversation mute, conversation set-top, conversation categories |
|
||||
| Calendar | `calendar` | 14 | `event` `room` `participant` `busy` | Events CRUD + suggested times + attachments, meeting room booking, free-busy query, participant management |
|
||||
| Todo | `todo` | 6 | `task` | Create, list, update, done, get detail, delete |
|
||||
| Approval | `oa` | 9 | `approval` | Approve / reject / revoke, pending / initiated instances, process list, operation records |
|
||||
@@ -366,16 +414,23 @@ 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 |
|
||||
| Sheet | `sheet` | 34 | `range` `filter-view` (top-level: `create` `new` `list` `info` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image` `copy_sheet` `update_sheet` `submit_export_job` `query_export_job` `create_filter` `get_filter` `update_filter` `delete_filter` `set_filter_criteria` `clear_filter_criteria` `sort_filter`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read/write/append, dimension ops, cell merge, find/replace, named filter views + sheet-level filters, image write, async export (`submit_export_job` + `query_export_job` — no consolidated `export` in v1.0.25) |
|
||||
| Wiki | `wiki` | 7 | `space` `member` | Knowledge base management: space `create` / `get` / `list` / `search` + member `add` / `list` / `update` |
|
||||
| DevDoc | `devdoc` | 1 | `article` | Search the DingTalk Open Platform documentation |
|
||||
| AI Search | `aisearch` | 1 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
|
||||
| AI App | `aiapp` | 3 | — | AI application lifecycle: `create` (with prompt / attachments / skills) / `query` (by task ID) / `modify` (by thread ID) |
|
||||
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
|
||||
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
|
||||
|
||||
> **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.
|
||||
> **209 commands across 19 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 meetings)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -424,7 +479,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
|
||||
|
||||
+63
-8
@@ -21,7 +21,7 @@
|
||||
> [!IMPORTANT]
|
||||
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
|
||||
>
|
||||
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
|
||||
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
|
||||
|
||||
<details>
|
||||
<summary><strong>目录</strong></summary>
|
||||
@@ -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
|
||||
@@ -348,6 +394,8 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "周报" --text @-
|
||||
```
|
||||
|
||||
> **说明**:`@` 仅在其后是 ASCII 路径前缀字符(`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`)或 `@-`(stdin)时,才会被识别为 `@<path>` 文件注入语法。`--text "@所有人 周报"` / `--text "@张三 看一下"` 这类机器人消息中的字面 `@` 提及会原样透传到 API。
|
||||
|
||||
</details>
|
||||
|
||||
## 核心服务
|
||||
@@ -355,7 +403,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|
||||
|------|------|:------:|--------|------|
|
||||
| 通讯录 | `contact` | 6 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
|
||||
| 群聊 | `chat`(别名 `im`)| 23 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` | 消息(发送 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索)、群增删改 + 成员管理(含 `add-bot`)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊 |
|
||||
| 群聊 | `chat`(别名 `im`)| 57 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | 消息(发送 / 回复 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索 / 高级搜索 / 转发 / 卡片 / 表情与文本表情反应 / 撤回 / 已读与发送状态查询)、群增删改 + 成员管理(成员增 / 删 / 查 / `add-bot`、成员角色增删改查、邀请链接、群图标、群设置、转让群主、设置管理员、退群)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊、群/成员/会话免打扰、会话置顶、会话分类 |
|
||||
| 日历 | `calendar` | 14 | `event` `room` `participant` `busy` | 日程 CRUD + 建议时间 + 附件、会议室预订、闲忙查询、参与者管理 |
|
||||
| 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 |
|
||||
| 审批 | `oa` | 9 | `approval` | 同意 / 拒绝 / 撤销、待我审批 / 我发起的、流程列表、操作记录 |
|
||||
@@ -366,16 +414,23 @@ 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 邮件搜索、邮件详情、发送邮件 |
|
||||
| 在线电子表格 | `sheet` | 34 | `range` `filter-view`(顶层:`create` `new` `list` `info` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image` `copy_sheet` `update_sheet` `submit_export_job` `query_export_job` `create_filter` `get_filter` `update_filter` `delete_filter` `set_filter_criteria` `clear_filter_criteria` `sort_filter`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并、查找替换、命名筛选视图 + 表级筛选、写入图片、异步导出(`submit_export_job` + `query_export_job`,v1.0.25 暂无合并的 `export` 命令) |
|
||||
| 知识库 | `wiki` | 7 | `space` `member` | 知识库管理:空间 `create` / `get` / `list` / `search` + 成员 `add` / `list` / `update` |
|
||||
| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 |
|
||||
| AI 搜问 | `aisearch` | 1 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
|
||||
| AI 应用 | `aiapp` | 3 | — | AI 应用生命周期:`create`(含 prompt / attachments / skills)/ `query`(按任务 ID)/ `modify`(按 thread ID) |
|
||||
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
|
||||
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
|
||||
|
||||
> **13 个产品,159 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
> **19 个产品,209 条命令。** 完整命令清单(带描述与使用场景):[`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`(视频会议)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -426,7 +481,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 = {
|
||||
|
||||
+7
-5
@@ -10,6 +10,7 @@
|
||||
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
|
||||
| `DWS_TRUSTED_DOMAINS` | Comma-separated trusted domains for bearer token (default: `*.dingtalk.com`). `*` for dev only / Bearer token 允许发送的域名白名单,默认 `*.dingtalk.com`,仅开发环境可设为 `*` |
|
||||
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 |
|
||||
| `DWS_DISABLE_KEYCHAIN` | macOS only. Set `1` to skip system Keychain for the encryption key and use file-based storage (same scheme as Linux). For sandboxed runtimes (e.g. Codex App) that block Keychain APIs. Weakens at-rest protection — DEK and ciphertext live in the same directory. / 仅 macOS。设为 `1` 时跳过系统 Keychain,密钥以文件形式存储(与 Linux 一致)。用于 Keychain API 被拦截的沙盒环境(如 Codex App)。代价是 DEK 与密文同目录,保护强度低于默认方案 |
|
||||
|
||||
## Exit Codes / 退出码
|
||||
|
||||
@@ -19,8 +20,9 @@
|
||||
| 1 | API | MCP tool call or upstream API failure / MCP 工具调用或上游 API 失败 |
|
||||
| 2 | Auth | Authentication or authorization failure / 身份认证或授权失败 |
|
||||
| 3 | Validation | Invalid input, flags, or parameter schema mismatch / 输入参数校验失败 |
|
||||
| 4 | Discovery | Server discovery, cache, or protocol negotiation failure / 服务发现失败 |
|
||||
| 4 | PAT | PAT authorization interception; stderr carries raw machine-readable PAT JSON / PAT 授权拦截;stderr 返回原始机器可解析 JSON |
|
||||
| 5 | Internal | Unexpected internal error / 未预期的内部错误 |
|
||||
| 6 | Discovery | Server discovery, cache, or protocol negotiation failure / 服务发现、缓存或协议协商失败 |
|
||||
|
||||
With `-f json`, error responses include structured payloads: `category`, `reason`, `hint`, `actions`.
|
||||
|
||||
@@ -29,9 +31,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 +46,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,228 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package apiclient provides a lightweight HTTP client for calling DingTalk
|
||||
// OpenAPI (https://api.dingtalk.com) directly, bypassing the MCP JSON-RPC
|
||||
// transport. It is used exclusively by the `dws api` command.
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultBaseURL is the DingTalk new-style OpenAPI base URL.
|
||||
DefaultBaseURL = "https://api.dingtalk.com"
|
||||
|
||||
// LegacyBaseURL is the DingTalk legacy (oapi) API base URL.
|
||||
LegacyBaseURL = "https://oapi.dingtalk.com"
|
||||
|
||||
// AuthHeader is the new-style OpenAPI authentication header.
|
||||
AuthHeader = "x-acs-dingtalk-access-token"
|
||||
|
||||
// LegacyAuthParam is the query parameter used for legacy API authentication.
|
||||
LegacyAuthParam = "access_token"
|
||||
)
|
||||
|
||||
// AllowedMethods is the set of HTTP methods permitted for raw API calls.
|
||||
var AllowedMethods = map[string]bool{
|
||||
"GET": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true,
|
||||
}
|
||||
|
||||
// RawAPIRequest describes a raw API request to DingTalk OpenAPI.
|
||||
type RawAPIRequest struct {
|
||||
Method string // GET, POST, PUT, PATCH, DELETE
|
||||
Path string // /v1.0/calendar/events or full URL
|
||||
Params map[string]any // query parameters
|
||||
Data any // request body (JSON), nil for GET
|
||||
}
|
||||
|
||||
// RawAPIResponse encapsulates the raw HTTP response.
|
||||
type RawAPIResponse struct {
|
||||
StatusCode int
|
||||
Header http.Header
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// APIClient wraps an HTTP client for DingTalk OpenAPI calls.
|
||||
type APIClient struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
Token string
|
||||
}
|
||||
|
||||
// NewClient creates an APIClient with sensible defaults.
|
||||
func NewClient(token, baseURL string) *APIClient {
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
baseURL = DefaultBaseURL
|
||||
}
|
||||
return &APIClient{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
Token: token,
|
||||
HTTPClient: &http.Client{
|
||||
Transport: defaultTransport(),
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Do sends a raw API request and returns the response.
|
||||
func (c *APIClient) Do(ctx context.Context, req RawAPIRequest) (*RawAPIResponse, error) {
|
||||
method := strings.ToUpper(strings.TrimSpace(req.Method))
|
||||
if !AllowedMethods[method] {
|
||||
return nil, fmt.Errorf("unsupported HTTP method: %s (allowed: GET, POST, PUT, PATCH, DELETE)", req.Method)
|
||||
}
|
||||
|
||||
fullURL, err := c.buildURL(req.Path, req.Params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building request URL: %w", err)
|
||||
}
|
||||
|
||||
// Security: verify target host before sending token.
|
||||
if err := ValidateTargetHost(fullURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if req.Data != nil && method != "GET" {
|
||||
data, marshalErr := json.Marshal(req.Data)
|
||||
if marshalErr != nil {
|
||||
return nil, fmt.Errorf("marshaling request body: %w", marshalErr)
|
||||
}
|
||||
bodyReader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating HTTP request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers and auth based on API style.
|
||||
if IsLegacyAPI(fullURL) {
|
||||
// Legacy API: token goes in query parameter.
|
||||
parsed, _ := url.Parse(fullURL)
|
||||
q := parsed.Query()
|
||||
q.Set(LegacyAuthParam, c.Token)
|
||||
parsed.RawQuery = q.Encode()
|
||||
httpReq.URL = parsed
|
||||
} else {
|
||||
// New API: token goes in header.
|
||||
httpReq.Header.Set(AuthHeader, c.Token)
|
||||
}
|
||||
if bodyReader != nil {
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
httpReq.Header.Set("User-Agent", "dws-cli/raw-api")
|
||||
|
||||
resp, err := c.HTTPClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("executing HTTP request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response body: %w", err)
|
||||
}
|
||||
|
||||
return &RawAPIResponse{
|
||||
StatusCode: resp.StatusCode,
|
||||
Header: resp.Header,
|
||||
Body: body,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildURL constructs the full request URL from path and query params.
|
||||
func (c *APIClient) buildURL(path string, params map[string]any) (string, error) {
|
||||
normalised := NormalisePath(path, c.BaseURL)
|
||||
parsed, err := url.Parse(normalised)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parsing URL %q: %w", normalised, err)
|
||||
}
|
||||
|
||||
if len(params) > 0 {
|
||||
q := parsed.Query()
|
||||
for k, v := range params {
|
||||
q.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
parsed.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
// IsLegacyAPI returns true if the URL targets the legacy oapi.dingtalk.com endpoint.
|
||||
// Legacy APIs use query-parameter authentication instead of header-based auth.
|
||||
func IsLegacyAPI(urlStr string) bool {
|
||||
lower := strings.ToLower(urlStr)
|
||||
return strings.Contains(lower, "oapi.dingtalk.com") ||
|
||||
strings.HasPrefix(lower, LegacyBaseURL)
|
||||
}
|
||||
|
||||
// NormalisePath normalises an API path:
|
||||
// - Full URLs are accepted as-is (after stripping query/fragment)
|
||||
// - Relative paths are prefixed with the base URL
|
||||
// - Query strings and fragments are stripped (must use --params)
|
||||
func NormalisePath(path, baseURL string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
|
||||
// Strip query and fragment to force --params usage.
|
||||
if idx := strings.IndexAny(path, "?#"); idx >= 0 {
|
||||
path = path[:idx]
|
||||
}
|
||||
|
||||
// Full URL: extract the path portion relative to the base.
|
||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
return path
|
||||
}
|
||||
|
||||
// Ensure leading slash.
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
baseURL = DefaultBaseURL
|
||||
}
|
||||
return strings.TrimRight(baseURL, "/") + path
|
||||
}
|
||||
|
||||
// defaultTransport returns a tuned http.Transport matching the project conventions.
|
||||
func defaultTransport() *http.Transport {
|
||||
return &http.Transport{
|
||||
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#236).
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 3 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: 20 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewClient_DefaultBaseURL(t *testing.T) {
|
||||
c := NewClient("tok", "")
|
||||
if c.BaseURL != DefaultBaseURL {
|
||||
t.Errorf("expected %q, got %q", DefaultBaseURL, c.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient_CustomBaseURL(t *testing.T) {
|
||||
c := NewClient("tok", "https://custom.api.com/")
|
||||
if c.BaseURL != "https://custom.api.com" {
|
||||
t.Errorf("expected trailing slash stripped, got %q", c.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalisePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path, base, want string
|
||||
}{
|
||||
{"/v1.0/users", "", "https://api.dingtalk.com/v1.0/users"},
|
||||
{"v1.0/users", "", "https://api.dingtalk.com/v1.0/users"},
|
||||
{"https://api.dingtalk.com/v1.0/users", "", "https://api.dingtalk.com/v1.0/users"},
|
||||
{"/v1.0/users?foo=bar#frag", "", "https://api.dingtalk.com/v1.0/users"},
|
||||
{"/v1.0/users", "https://custom.example.com", "https://custom.example.com/v1.0/users"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := NormalisePath(tt.path, tt.base)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalisePath(%q, %q) = %q, want %q", tt.path, tt.base, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_Success(t *testing.T) {
|
||||
AllowedHosts["127.0.0.1"] = true
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(AuthHeader) != "test-token" {
|
||||
t.Errorf("expected auth header %q, got %q", "test-token", r.Header.Get(AuthHeader))
|
||||
}
|
||||
if r.Method != "GET" {
|
||||
t.Errorf("expected GET, got %s", r.Method)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]string{"name": "test"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("test-token", srv.URL)
|
||||
resp, err := c.Do(context.Background(), RawAPIRequest{
|
||||
Method: "GET",
|
||||
Path: "/v1.0/test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_PostWithBody(t *testing.T) {
|
||||
AllowedHosts["127.0.0.1"] = true
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("expected JSON content type")
|
||||
}
|
||||
var body map[string]string
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["key"] != "value" {
|
||||
t.Errorf("expected body key=value, got %v", body)
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("tok", srv.URL)
|
||||
resp, err := c.Do(context.Background(), RawAPIRequest{
|
||||
Method: "POST",
|
||||
Path: "/v1.0/test",
|
||||
Data: map[string]string{"key": "value"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_InvalidMethod(t *testing.T) {
|
||||
c := NewClient("tok", "")
|
||||
_, err := c.Do(context.Background(), RawAPIRequest{
|
||||
Method: "INVALID",
|
||||
Path: "/test",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid method")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_QueryParams(t *testing.T) {
|
||||
AllowedHosts["127.0.0.1"] = true
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("pageSize") != "10" {
|
||||
t.Errorf("expected pageSize=10, got %v", r.URL.Query())
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("tok", srv.URL)
|
||||
_, err := c.Do(context.Background(), RawAPIRequest{
|
||||
Method: "GET",
|
||||
Path: "/v1.0/test",
|
||||
Params: map[string]any{"pageSize": 10},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLegacyAPI(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
want bool
|
||||
}{
|
||||
{"https://api.dingtalk.com/v1.0/users", false},
|
||||
{"https://oapi.dingtalk.com/topapi/v2/user/get", true},
|
||||
{"https://OAPI.DINGTALK.COM/topapi/v2/user/get", true},
|
||||
{"https://custom.example.com/api", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := IsLegacyAPI(tt.url)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsLegacyAPI(%q) = %v, want %v", tt.url, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_LegacyAPI_TokenInQueryParam(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Legacy API: token should be in query param.
|
||||
if r.URL.Query().Get(LegacyAuthParam) != "legacy-token" {
|
||||
t.Errorf("expected access_token=legacy-token in query, got %v", r.URL.Query())
|
||||
}
|
||||
// Should NOT have the new-style auth header.
|
||||
if r.Header.Get(AuthHeader) != "" {
|
||||
t.Errorf("expected no auth header for legacy API, got %q", r.Header.Get(AuthHeader))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{"errcode":0,"errmsg":"ok","result":{"userid":"user1"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Use full URL with oapi.dingtalk.com in the path, but redirect to test server.
|
||||
// Since we can't DNS-resolve oapi.dingtalk.com, we use the test server URL
|
||||
// and pass the full oapi URL as Path so that NormalisePath preserves it.
|
||||
// Then we override the resolved URL in the client to point to our test server.
|
||||
//
|
||||
// Best approach: directly verify that buildURL + IsLegacyAPI routing works
|
||||
// by testing buildURL output and calling Do with a custom transport that
|
||||
// redirects oapi.dingtalk.com to our test server.
|
||||
c := NewClient("legacy-token", "")
|
||||
// Replace the transport to redirect oapi.dingtalk.com to test server.
|
||||
c.HTTPClient.Transport = &legacyTestTransport{targetURL: srv.URL}
|
||||
|
||||
resp, err := c.Do(context.Background(), RawAPIRequest{
|
||||
Method: "POST",
|
||||
Path: "https://oapi.dingtalk.com/topapi/v2/user/get",
|
||||
Data: map[string]string{"userid": "user1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// legacyTestTransport redirects requests from oapi.dingtalk.com to a local test server.
|
||||
type legacyTestTransport struct {
|
||||
targetURL string
|
||||
}
|
||||
|
||||
func (t *legacyTestTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// Rewrite the host to point to our test server, preserving path and query.
|
||||
newURL := t.targetURL + req.URL.Path
|
||||
if req.URL.RawQuery != "" {
|
||||
newURL += "?" + req.URL.RawQuery
|
||||
}
|
||||
parsed, _ := url.Parse(newURL)
|
||||
req.URL = parsed
|
||||
req.Host = parsed.Host
|
||||
return http.DefaultTransport.RoundTrip(req)
|
||||
}
|
||||
|
||||
func TestNormalisePath_Legacy(t *testing.T) {
|
||||
tests := []struct {
|
||||
path, base, want string
|
||||
}{
|
||||
// Legacy full URL preserved.
|
||||
{"https://oapi.dingtalk.com/topapi/v2/user/get", "", "https://oapi.dingtalk.com/topapi/v2/user/get"},
|
||||
// Relative path with legacy base URL.
|
||||
{"/topapi/v2/user/get", LegacyBaseURL, "https://oapi.dingtalk.com/topapi/v2/user/get"},
|
||||
// Strip query from legacy URL.
|
||||
{"https://oapi.dingtalk.com/topapi/v2/user/get?access_token=xxx", "", "https://oapi.dingtalk.com/topapi/v2/user/get"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := NormalisePath(tt.path, tt.base)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalisePath(%q, %q) = %q, want %q", tt.path, tt.base, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePageLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
raw, want int
|
||||
}{
|
||||
// 0 → unlimited → safety cap
|
||||
{0, MaxPageLimit},
|
||||
// normal usage
|
||||
{3, 3},
|
||||
// default
|
||||
{10, 10},
|
||||
// within cap
|
||||
{100, 100},
|
||||
// exactly cap
|
||||
{MaxPageLimit, MaxPageLimit},
|
||||
// exceeds cap
|
||||
{MaxPageLimit + 100, MaxPageLimit},
|
||||
// negative → default
|
||||
{-1, DefaultPageLimit},
|
||||
{-100, DefaultPageLimit},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := resolvePageLimit(tt.raw)
|
||||
if got != tt.want {
|
||||
t.Errorf("resolvePageLimit(%d) = %d, want %d", tt.raw, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateAll_ProgressLog(t *testing.T) {
|
||||
AllowedHosts["127.0.0.1"] = true
|
||||
callCount := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if callCount >= 3 {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"result": map[string]any{"has_more": false, "items": []any{1, 2}},
|
||||
})
|
||||
} else {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"result": map[string]any{
|
||||
"has_more": true,
|
||||
"next_cursor": 100,
|
||||
"items": []any{callCount},
|
||||
},
|
||||
})
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("test-token", srv.URL)
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
pages, err := c.PaginateAll(context.Background(), RawAPIRequest{
|
||||
Method: "GET",
|
||||
Path: "/v1.0/test",
|
||||
}, PaginationOptions{
|
||||
PageLimit: 5,
|
||||
PageDelay: 0,
|
||||
LogWriter: &logBuf,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(pages) != 3 {
|
||||
t.Errorf("expected 3 pages, got %d", len(pages))
|
||||
}
|
||||
|
||||
log := logBuf.String()
|
||||
if !strings.Contains(log, "第 1 页") || !strings.Contains(log, "第 2 页") || !strings.Contains(log, "第 3 页") {
|
||||
t.Errorf("expected progress log for each page, got: %s", log)
|
||||
}
|
||||
if !strings.Contains(log, "数据获取完成") {
|
||||
t.Errorf("expected completion message, got: %s", log)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaskToken returns a masked version of a token for display in dry-run
|
||||
// and log output. Shows the first 4 characters followed by "****".
|
||||
func MaskToken(token string) string {
|
||||
if len(token) <= 4 {
|
||||
return "****"
|
||||
}
|
||||
return token[:4] + "****"
|
||||
}
|
||||
|
||||
// PrintDryRun outputs a dry-run preview of the API request that would be sent.
|
||||
func PrintDryRun(w io.Writer, req RawAPIRequest, baseURL, token string) error {
|
||||
fullURL := NormalisePath(req.Path, baseURL)
|
||||
|
||||
fmt.Fprintln(w, "=== Dry Run ===")
|
||||
fmt.Fprintf(w, "%-12s%s\n", "Method:", strings.ToUpper(req.Method))
|
||||
fmt.Fprintf(w, "%-12s%s\n", "URL:", fullURL)
|
||||
|
||||
if len(req.Params) > 0 {
|
||||
paramsJSON, err := json.MarshalIndent(req.Params, " ", " ")
|
||||
if err == nil {
|
||||
fmt.Fprintf(w, "%-12s%s\n", "Params:", string(paramsJSON))
|
||||
}
|
||||
}
|
||||
|
||||
if req.Data != nil {
|
||||
dataJSON, err := json.MarshalIndent(req.Data, " ", " ")
|
||||
if err == nil {
|
||||
fmt.Fprintf(w, "%-12s%s\n", "Body:", string(dataJSON))
|
||||
}
|
||||
}
|
||||
|
||||
if IsLegacyAPI(fullURL) {
|
||||
fmt.Fprintf(w, "%-12s%s=%s\n", "Auth:", LegacyAuthParam, MaskToken(token))
|
||||
fmt.Fprintf(w, "%-12s%s\n", "Style:", "旧版 (oapi.dingtalk.com)")
|
||||
} else {
|
||||
fmt.Fprintf(w, "%-12s%s: %s\n", "Auth:", AuthHeader, MaskToken(token))
|
||||
fmt.Fprintf(w, "%-12s%s\n", "Style:", "新版 (api.dingtalk.com)")
|
||||
}
|
||||
fmt.Fprintln(w, "===============")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultPageLimit is the maximum number of pages fetched with --page-all
|
||||
// when --page-limit is not explicitly set.
|
||||
DefaultPageLimit = 10
|
||||
|
||||
// MaxPageLimit is the hard safety cap to prevent infinite loops when an
|
||||
// API endpoint has a bug that causes has_more to never become false.
|
||||
// Use --page-limit 0 to hit this cap; any explicit positive value is
|
||||
// honoured up to this ceiling.
|
||||
MaxPageLimit = 500
|
||||
|
||||
// DefaultPageDelay is the delay between paginated requests in milliseconds.
|
||||
DefaultPageDelay = 200
|
||||
)
|
||||
|
||||
// PaginationOptions controls automatic pagination behaviour.
|
||||
type PaginationOptions struct {
|
||||
PageLimit int // Maximum pages (0 = unlimited, capped at MaxPageLimit)
|
||||
PageDelay int // Delay between pages in milliseconds
|
||||
LogWriter io.Writer // Optional: progress log output (typically stderr)
|
||||
}
|
||||
|
||||
// PaginateAll fetches all pages of a paginated API and merges the results.
|
||||
// DingTalk APIs use two pagination patterns:
|
||||
// - cursor/next_cursor/has_more (in response body)
|
||||
// - next_token (in response body)
|
||||
//
|
||||
// The function auto-detects which pattern the API uses.
|
||||
func (c *APIClient) PaginateAll(ctx context.Context, req RawAPIRequest, opts PaginationOptions) ([]any, error) {
|
||||
limit := resolvePageLimit(opts.PageLimit)
|
||||
if opts.PageDelay <= 0 {
|
||||
opts.PageDelay = DefaultPageDelay
|
||||
}
|
||||
|
||||
var allResults []any
|
||||
pageCount := 0
|
||||
|
||||
for {
|
||||
pageCount++
|
||||
|
||||
// Safety cap — only break if a carry is active (pageCount > 1).
|
||||
if limit > 0 && pageCount > limit {
|
||||
logf(opts.LogWriter, "[pagination] ⚠ 已达安全上限 %d 页,停止翻页。数据可能不完整,请检查 API 是否异常。\n", limit)
|
||||
break
|
||||
}
|
||||
|
||||
logf(opts.LogWriter, "[pagination] 第 %d 页 请求中...\n", pageCount)
|
||||
|
||||
resp, err := c.Do(ctx, req)
|
||||
if err != nil {
|
||||
if pageCount == 1 {
|
||||
return nil, err
|
||||
}
|
||||
// Non-first page error: return what we have so far.
|
||||
return allResults, fmt.Errorf("分页第 %d 页请求失败 (已获取 %d 页结果): %w", pageCount, pageCount-1, err)
|
||||
}
|
||||
|
||||
result, hasMore, nextToken, parseErr := parsePaginatedResponse(resp)
|
||||
if parseErr != nil {
|
||||
if pageCount == 1 {
|
||||
return nil, parseErr
|
||||
}
|
||||
// Non-first page parse failure: warn the caller so users aren't
|
||||
// silently left with incomplete data.
|
||||
logf(opts.LogWriter, "[pagination] ⚠ 第 %d 页解析失败,停止翻页并返回已获取的 %d 页数据: %v\n", pageCount, pageCount-1, parseErr)
|
||||
return allResults, nil
|
||||
}
|
||||
|
||||
allResults = append(allResults, result)
|
||||
|
||||
if !hasMore || nextToken == "" {
|
||||
logf(opts.LogWriter, "[pagination] 数据获取完成 (共 %d 页)\n", pageCount)
|
||||
break
|
||||
}
|
||||
|
||||
// Inject the next page token into the request.
|
||||
req = injectPageToken(req, nextToken)
|
||||
|
||||
// Delay between pages to prevent API throttling.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return allResults, ctx.Err()
|
||||
case <-time.After(time.Duration(opts.PageDelay) * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
return allResults, nil
|
||||
}
|
||||
|
||||
// resolvePageLimit translates the user-facing value into an internal limit:
|
||||
//
|
||||
// 0 → MaxPageLimit (user wants unlimited; safety cap applies)
|
||||
// positive N → min(N, MaxPageLimit) (explicit page limit, still capped)
|
||||
// negative → DefaultPageLimit (invalid input treated as default)
|
||||
func resolvePageLimit(raw int) int {
|
||||
if raw == 0 {
|
||||
return MaxPageLimit
|
||||
}
|
||||
if raw < 0 {
|
||||
return DefaultPageLimit
|
||||
}
|
||||
if raw > MaxPageLimit {
|
||||
return MaxPageLimit
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func logf(w io.Writer, format string, args ...any) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, format, args...)
|
||||
}
|
||||
|
||||
// parsePaginatedResponse extracts the response payload and pagination info.
|
||||
// It auto-detects DingTalk's two pagination patterns.
|
||||
func parsePaginatedResponse(resp *RawAPIResponse) (result any, hasMore bool, nextToken string, err error) {
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if !isJSONContentType(contentType) {
|
||||
return nil, false, "", fmt.Errorf("分页响应非 JSON 格式 (Content-Type: %s)", contentType)
|
||||
}
|
||||
|
||||
if len(resp.Body) == 0 {
|
||||
return nil, false, "", fmt.Errorf("分页响应体为空 (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if unmarshalErr := jsonUnmarshal(resp.Body, &payload); unmarshalErr != nil {
|
||||
return nil, false, "", fmt.Errorf("解析分页 JSON 响应失败: %w", unmarshalErr)
|
||||
}
|
||||
|
||||
// Check for DingTalk errors first.
|
||||
if apiErr := checkDingTalkError(payload, resp.StatusCode); apiErr != nil {
|
||||
return nil, false, "", apiErr
|
||||
}
|
||||
|
||||
// Pattern 1: cursor/next_cursor/has_more (often nested in "result" or top-level)
|
||||
if resultObj, ok := payload["result"]; ok {
|
||||
if resultMap, isMap := resultObj.(map[string]any); isMap {
|
||||
hasMore, _ = resultMap["has_more"].(bool)
|
||||
if nc, ok := resultMap["next_cursor"].(float64); ok && nc > 0 {
|
||||
nextToken = fmt.Sprintf("%.0f", nc)
|
||||
}
|
||||
return payload, hasMore, nextToken, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Top-level has_more / next_cursor
|
||||
if hm, ok := payload["has_more"]; ok {
|
||||
hasMore, _ = hm.(bool)
|
||||
}
|
||||
if nc, ok := payload["next_cursor"]; ok {
|
||||
if ncf, isFloat := nc.(float64); isFloat && ncf > 0 {
|
||||
nextToken = fmt.Sprintf("%.0f", ncf)
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 2: next_token
|
||||
if nt, ok := payload["next_token"]; ok {
|
||||
if nts, isStr := nt.(string); isStr && nts != "" {
|
||||
nextToken = nts
|
||||
hasMore = true
|
||||
}
|
||||
}
|
||||
|
||||
return payload, hasMore, nextToken, nil
|
||||
}
|
||||
|
||||
// injectPageToken injects the pagination token into the next request.
|
||||
// For GET requests, it's added as a query param; for POST, it's in the body.
|
||||
func injectPageToken(req RawAPIRequest, token string) RawAPIRequest {
|
||||
method := req.Method
|
||||
if method == "GET" {
|
||||
if req.Params == nil {
|
||||
req.Params = make(map[string]any)
|
||||
}
|
||||
// Try to detect which param name the API uses
|
||||
if _, ok := req.Params["cursor"]; ok {
|
||||
req.Params["cursor"] = token
|
||||
} else if _, ok := req.Params["next_token"]; ok {
|
||||
req.Params["next_token"] = token
|
||||
} else {
|
||||
// Default to next_token for GET requests
|
||||
req.Params["next_token"] = token
|
||||
}
|
||||
} else {
|
||||
// For POST/PUT requests, inject into the body
|
||||
if bodyMap, ok := req.Data.(map[string]any); ok {
|
||||
if _, hasCursor := bodyMap["cursor"]; hasCursor {
|
||||
bodyMap["cursor"] = token
|
||||
} else {
|
||||
bodyMap["next_token"] = token
|
||||
}
|
||||
req.Data = bodyMap
|
||||
}
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// jsonUnmarshal is a helper for JSON unmarshaling.
|
||||
func jsonUnmarshal(data []byte, v any) error {
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseJSONMap parses a --params flag value into a map[string]any.
|
||||
// Supports:
|
||||
// - JSON string: '{"key":"value"}'
|
||||
// - "-" to read from stdin
|
||||
// - Empty string returns nil (no params)
|
||||
func ParseJSONMap(raw, flagName string, stdin io.Reader) (map[string]any, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if raw == "-" {
|
||||
data, err := io.ReadAll(stdin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("从 stdin 读取 %s 失败: %w", flagName, err)
|
||||
}
|
||||
raw = strings.TrimSpace(string(data))
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Strip wrapping single quotes (common shell escaping).
|
||||
raw = stripSingleQuotes(raw)
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &result); err != nil {
|
||||
return nil, fmt.Errorf("解析 %s JSON 失败: %w\n输入: %s", flagName, err, truncate(raw, 200))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ParseOptionalBody parses a --data flag value into a request body.
|
||||
// Returns nil for empty input. GET requests are not allowed to have a body.
|
||||
func ParseOptionalBody(method, raw string, stdin io.Reader) (any, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if strings.ToUpper(method) == "GET" && raw != "" {
|
||||
return nil, fmt.Errorf("GET 请求不允许使用 --data 参数")
|
||||
}
|
||||
|
||||
if raw == "-" {
|
||||
data, err := io.ReadAll(stdin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("从 stdin 读取 --data 失败: %w", err)
|
||||
}
|
||||
raw = strings.TrimSpace(string(data))
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Strip wrapping single quotes.
|
||||
raw = stripSingleQuotes(raw)
|
||||
|
||||
var result any
|
||||
if err := json.Unmarshal([]byte(raw), &result); err != nil {
|
||||
return nil, fmt.Errorf("解析 --data JSON 失败: %w\n输入: %s", err, truncate(raw, 200))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// stripSingleQuotes removes a leading and trailing single quote pair.
|
||||
func stripSingleQuotes(s string) string {
|
||||
if len(s) >= 2 && s[0] == '\'' && s[len(s)-1] == '\'' {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// truncate returns at most n characters of s, appending "..." if truncated.
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseJSONMap_Empty(t *testing.T) {
|
||||
result, err := ParseJSONMap("", "--params", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("expected nil, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONMap_ValidJSON(t *testing.T) {
|
||||
result, err := ParseJSONMap(`{"key":"value","num":42}`, "--params", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result["key"] != "value" {
|
||||
t.Errorf("expected key=value, got %v", result["key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONMap_SingleQuotes(t *testing.T) {
|
||||
result, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result["key"] != "value" {
|
||||
t.Errorf("expected key=value, got %v", result["key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONMap_Stdin(t *testing.T) {
|
||||
stdin := strings.NewReader(`{"from":"stdin"}`)
|
||||
result, err := ParseJSONMap("-", "--params", stdin)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result["from"] != "stdin" {
|
||||
t.Errorf("expected from=stdin, got %v", result["from"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONMap_InvalidJSON(t *testing.T) {
|
||||
_, err := ParseJSONMap("not json", "--params", nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOptionalBody_Empty(t *testing.T) {
|
||||
result, err := ParseOptionalBody("POST", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("expected nil, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOptionalBody_GETNotAllowed(t *testing.T) {
|
||||
_, err := ParseOptionalBody("GET", `{"data":true}`, nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for GET with body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOptionalBody_ValidPOST(t *testing.T) {
|
||||
result, err := ParseOptionalBody("POST", `{"key":"value"}`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m, ok := result.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected map, got %T", result)
|
||||
}
|
||||
if m["key"] != "value" {
|
||||
t.Errorf("expected key=value, got %v", m["key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripSingleQuotes(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{`'hello'`, `hello`},
|
||||
{`"hello"`, `"hello"`},
|
||||
{`hello`, `hello`},
|
||||
{`''`, ``},
|
||||
{`'`, `'`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := stripSingleQuotes(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripSingleQuotes(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
if got := truncate("hello", 10); got != "hello" {
|
||||
t.Errorf("expected hello, got %q", got)
|
||||
}
|
||||
if got := truncate("hello world", 5); got != "hello..." {
|
||||
t.Errorf("expected hello..., got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDefaultTransportHonoursHTTPProxyEnv is the regression guard for #236
|
||||
// on the apiclient transport. Same rationale as transport/proxy_env_test.go:
|
||||
// a custom Transport without an explicit Proxy field silently bypasses
|
||||
// HTTP_PROXY/HTTPS_PROXY.
|
||||
//
|
||||
// We pointer-compare against http.ProxyFromEnvironment instead of invoking
|
||||
// it, because http.ProxyFromEnvironment memoises the env on first call;
|
||||
// other tests that read proxy env early would make a value-based assertion
|
||||
// flaky.
|
||||
func TestDefaultTransportHonoursHTTPProxyEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tr := defaultTransport()
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("defaultTransport().Proxy is nil — HTTP_PROXY env will be ignored (regression of #236)")
|
||||
}
|
||||
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
|
||||
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
|
||||
if gotPC != wantPC {
|
||||
t.Errorf("defaultTransport().Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
// ResponseOptions controls how an API response is processed.
|
||||
type ResponseOptions struct {
|
||||
OutputPath string // --output file path for binary responses
|
||||
Format output.Format // output format (json|table|raw)
|
||||
JqExpr string // --jq expression
|
||||
Fields string // --fields comma-separated field names
|
||||
Out io.Writer // stdout
|
||||
ErrOut io.Writer // stderr
|
||||
}
|
||||
|
||||
// HandleResponse routes response processing based on Content-Type and status code.
|
||||
func HandleResponse(resp *RawAPIResponse, opts ResponseOptions) error {
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
isJSON := isJSONContentType(contentType)
|
||||
|
||||
// HTTP error with non-JSON body: print as plain text error.
|
||||
if resp.StatusCode >= 400 && !isJSON {
|
||||
return fmt.Errorf("API 请求失败 (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(resp.Body)))
|
||||
}
|
||||
|
||||
// JSON response
|
||||
if isJSON {
|
||||
return handleJSONResponse(resp, opts)
|
||||
}
|
||||
|
||||
// Binary response
|
||||
return handleBinaryResponse(resp, opts)
|
||||
}
|
||||
|
||||
// handleJSONResponse parses the JSON body, checks for DingTalk business errors,
|
||||
// and writes the output using the configured format and filters.
|
||||
func handleJSONResponse(resp *RawAPIResponse, opts ResponseOptions) error {
|
||||
if len(resp.Body) == 0 {
|
||||
return fmt.Errorf("API 返回空响应体 (HTTP %d),如需下载文件请使用 --output 参数", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload any
|
||||
if err := json.Unmarshal(resp.Body, &payload); err != nil {
|
||||
return fmt.Errorf("解析 JSON 响应失败: %w", err)
|
||||
}
|
||||
|
||||
// Check for DingTalk business error: {"errcode": xxx, "errmsg": "xxx"}
|
||||
if apiErr := checkDingTalkError(payload, resp.StatusCode); apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
|
||||
return output.WriteFiltered(opts.Out, opts.Format, payload, opts.Fields, opts.JqExpr)
|
||||
}
|
||||
|
||||
// checkDingTalkError inspects a parsed JSON response for DingTalk error codes.
|
||||
// Returns nil if no error is detected.
|
||||
func checkDingTalkError(payload any, statusCode int) error {
|
||||
obj, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for errcode != 0
|
||||
if errcode, hasCode := obj["errcode"]; hasCode {
|
||||
code := toFloat64(errcode)
|
||||
if code != 0 {
|
||||
errmsg, _ := obj["errmsg"].(string)
|
||||
if errmsg == "" {
|
||||
errmsg = "unknown error"
|
||||
}
|
||||
return fmt.Errorf("API 业务错误 (errcode: %.0f, HTTP %d): %s", code, statusCode, errmsg)
|
||||
}
|
||||
}
|
||||
|
||||
// Also check HTTP error status even if no errcode field
|
||||
if statusCode >= 400 {
|
||||
errmsg, _ := obj["errmsg"].(string)
|
||||
if errmsg == "" {
|
||||
errmsg, _ = obj["message"].(string)
|
||||
}
|
||||
if errmsg == "" {
|
||||
errmsg, _ = obj["error"].(string)
|
||||
}
|
||||
if errmsg != "" {
|
||||
return fmt.Errorf("API 请求失败 (HTTP %d): %s", statusCode, errmsg)
|
||||
}
|
||||
return fmt.Errorf("API 请求失败 (HTTP %d)", statusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleBinaryResponse saves the response body to a file.
|
||||
func handleBinaryResponse(resp *RawAPIResponse, opts ResponseOptions) error {
|
||||
outputPath := strings.TrimSpace(opts.OutputPath)
|
||||
|
||||
if outputPath == "" {
|
||||
// Try to infer filename from Content-Disposition header.
|
||||
outputPath = inferFilename(resp.Header)
|
||||
if outputPath == "" {
|
||||
return fmt.Errorf("响应为非 JSON 格式 (Content-Type: %s),请使用 --output 指定保存路径",
|
||||
resp.Header.Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
dir := filepath.Dir(outputPath)
|
||||
if dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("创建输出目录失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outputPath, resp.Body, 0o644); err != nil {
|
||||
return fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(opts.ErrOut, "已保存到: %s (%d 字节)\n", outputPath, len(resp.Body))
|
||||
return nil
|
||||
}
|
||||
|
||||
// inferFilename tries to extract a filename from the Content-Disposition header.
|
||||
func inferFilename(header http.Header) string {
|
||||
cd := header.Get("Content-Disposition")
|
||||
if cd == "" {
|
||||
return ""
|
||||
}
|
||||
_, params, err := mime.ParseMediaType(cd)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(params["filename"])
|
||||
}
|
||||
|
||||
// isJSONContentType returns true if the Content-Type indicates JSON.
|
||||
func isJSONContentType(ct string) bool {
|
||||
ct = strings.TrimSpace(strings.ToLower(ct))
|
||||
return strings.HasPrefix(ct, "application/json") ||
|
||||
strings.HasPrefix(ct, "text/json") ||
|
||||
strings.Contains(ct, "+json")
|
||||
}
|
||||
|
||||
// toFloat64 attempts to convert a JSON number to float64.
|
||||
func toFloat64(v any) float64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case int:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
case json.Number:
|
||||
f, _ := n.Float64()
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AllowedHosts is the set of trusted DingTalk API hosts.
|
||||
// Only these hosts may receive access tokens to prevent token leakage.
|
||||
var AllowedHosts = map[string]bool{
|
||||
"api.dingtalk.com": true,
|
||||
"oapi.dingtalk.com": true,
|
||||
}
|
||||
|
||||
// ValidateTargetHost checks that the resolved request URL targets a trusted
|
||||
// DingTalk host. This prevents access-token leakage to arbitrary domains.
|
||||
func ValidateTargetHost(fullURL string) error {
|
||||
parsed, err := url.Parse(fullURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法解析请求 URL: %w", err)
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
if !AllowedHosts[host] {
|
||||
return fmt.Errorf(
|
||||
"安全限制: 目标域名 %q 不在允许列表中。\n"+
|
||||
"dws api 仅允许向以下域名发起请求:\n"+
|
||||
" - api.dingtalk.com (新版 API)\n"+
|
||||
" - oapi.dingtalk.com (旧版 API)\n"+
|
||||
"请检查 URL 或 --base-url 参数是否正确。",
|
||||
host,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateMethod checks that the HTTP method is one of the five allowed methods.
|
||||
func ValidateMethod(method string) (string, error) {
|
||||
upper := strings.ToUpper(strings.TrimSpace(method))
|
||||
if !AllowedMethods[upper] {
|
||||
return "", fmt.Errorf("不支持的 HTTP 方法: %s (允许: GET, POST, PUT, PATCH, DELETE)", method)
|
||||
}
|
||||
return upper, nil
|
||||
}
|
||||
|
||||
// ValidatePath checks the API path for injection attacks and dangerous characters.
|
||||
func ValidatePath(path string) error {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return fmt.Errorf("API 路径不能为空")
|
||||
}
|
||||
if err := rejectDangerousChars(path, "path"); err != nil {
|
||||
return err
|
||||
}
|
||||
// Reject path traversal
|
||||
if strings.Contains(path, "..") {
|
||||
return fmt.Errorf("API 路径不能包含 '..' (路径遍历)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateUserInput checks a user-provided string for control characters and
|
||||
// dangerous Unicode codepoints that could enable injection attacks.
|
||||
func ValidateUserInput(value, fieldName string) error {
|
||||
return rejectDangerousChars(value, fieldName)
|
||||
}
|
||||
|
||||
// rejectDangerousChars rejects C0 control characters (except \t and \n),
|
||||
// DEL (0x7F), and dangerous Unicode codepoints in a string.
|
||||
func rejectDangerousChars(s, fieldName string) error {
|
||||
for i, r := range s {
|
||||
// Allow tab and newline
|
||||
if r == '\t' || r == '\n' {
|
||||
continue
|
||||
}
|
||||
// Reject C0 control chars (0x00-0x1F) and DEL (0x7F)
|
||||
if r < 0x20 || r == 0x7F {
|
||||
return fmt.Errorf("%s 包含非法控制字符 (位置 %d, U+%04X)", fieldName, i, r)
|
||||
}
|
||||
// Reject dangerous Unicode
|
||||
if isDangerousUnicode(r) {
|
||||
return fmt.Errorf("%s 包含危险 Unicode 字符 (位置 %d, U+%04X)", fieldName, i, r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isDangerousUnicode returns true for Unicode codepoints that can be used
|
||||
// for visual spoofing or terminal injection attacks.
|
||||
func isDangerousUnicode(r rune) bool {
|
||||
switch {
|
||||
// Zero-width characters
|
||||
case r >= 0x200B && r <= 0x200D:
|
||||
return true
|
||||
// BOM
|
||||
case r == 0xFEFF:
|
||||
return true
|
||||
// Bidi override characters
|
||||
case r >= 0x202A && r <= 0x202E:
|
||||
return true
|
||||
// Line/paragraph separator
|
||||
case r == 0x2028 || r == 0x2029:
|
||||
return true
|
||||
// Bidi isolate characters
|
||||
case r >= 0x2066 && r <= 0x2069:
|
||||
return true
|
||||
// Additional Bidi controls
|
||||
case r == 0x061C:
|
||||
return true
|
||||
// Non-characters
|
||||
case r >= 0xFDD0 && r <= 0xFDEF:
|
||||
return true
|
||||
}
|
||||
// Object replacement (U+FFFC) / replacement (U+FFFD) characters and
|
||||
// other non-printable non-ASCII runes (e.g. CJK, symbols) are allowed
|
||||
// through — only the explicit dangerous ranges above are blocked.
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidateStdinExclusion checks that --params and --data don't both read from stdin.
|
||||
func ValidateStdinExclusion(params, data string) error {
|
||||
if strings.TrimSpace(params) == "-" && strings.TrimSpace(data) == "-" {
|
||||
return fmt.Errorf("--params 和 --data 不能同时从 stdin 读取 (-)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateFlagExclusion checks mutual exclusion between flags.
|
||||
func ValidateFlagExclusion(outputPath string, pageAll bool) error {
|
||||
if strings.TrimSpace(outputPath) != "" && pageAll {
|
||||
return fmt.Errorf("--output 和 --page-all 不能同时使用")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateMethod(t *testing.T) {
|
||||
valid := []string{"GET", "get", "Post", "put", "PATCH", "delete"}
|
||||
for _, m := range valid {
|
||||
got, err := ValidateMethod(m)
|
||||
if err != nil {
|
||||
t.Errorf("ValidateMethod(%q) unexpected error: %v", m, err)
|
||||
}
|
||||
if got != strings.ToUpper(m) {
|
||||
t.Errorf("ValidateMethod(%q) = %q, want %q", m, got, strings.ToUpper(m))
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{"HEAD", "OPTIONS", "TRACE", "CONNECT", "INVALID", ""}
|
||||
for _, m := range invalid {
|
||||
_, err := ValidateMethod(m)
|
||||
if err == nil {
|
||||
t.Errorf("ValidateMethod(%q) expected error, got nil", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePath(t *testing.T) {
|
||||
// Valid paths
|
||||
for _, p := range []string{"/v1.0/users", "/v2.0/calendar/events", "v1.0/contact/users/me"} {
|
||||
if err := ValidatePath(p); err != nil {
|
||||
t.Errorf("ValidatePath(%q) unexpected error: %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Empty path
|
||||
if err := ValidatePath(""); err == nil {
|
||||
t.Error("ValidatePath(\"\") expected error")
|
||||
}
|
||||
|
||||
// Path traversal
|
||||
if err := ValidatePath("/v1.0/../secret"); err == nil {
|
||||
t.Error("ValidatePath with .. expected error")
|
||||
}
|
||||
|
||||
// Control character
|
||||
if err := ValidatePath("/v1.0/\x00test"); err == nil {
|
||||
t.Error("ValidatePath with null byte expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectDangerousUnicode(t *testing.T) {
|
||||
// Zero-width space
|
||||
if err := ValidateUserInput("hello\u200Bworld", "test"); err == nil {
|
||||
t.Error("expected error for zero-width space")
|
||||
}
|
||||
// BOM
|
||||
if err := ValidateUserInput("\uFEFFhello", "test"); err == nil {
|
||||
t.Error("expected error for BOM")
|
||||
}
|
||||
// Bidi override
|
||||
if err := ValidateUserInput("hello\u202Aworld", "test"); err == nil {
|
||||
t.Error("expected error for bidi override")
|
||||
}
|
||||
// Normal string should pass
|
||||
if err := ValidateUserInput("hello world 你好", "test"); err != nil {
|
||||
t.Errorf("unexpected error for normal string: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStdinExclusion(t *testing.T) {
|
||||
if err := ValidateStdinExclusion("-", "-"); err == nil {
|
||||
t.Error("expected error when both params and data read from stdin")
|
||||
}
|
||||
if err := ValidateStdinExclusion("-", "{}"); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if err := ValidateStdinExclusion("{}", "-"); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFlagExclusion(t *testing.T) {
|
||||
if err := ValidateFlagExclusion("output.json", true); err == nil {
|
||||
t.Error("expected error when --output and --page-all both set")
|
||||
}
|
||||
if err := ValidateFlagExclusion("output.json", false); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if err := ValidateFlagExclusion("", true); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"", "****"},
|
||||
{"abc", "****"},
|
||||
{"abcd", "****"},
|
||||
{"abcde", "abcd****"},
|
||||
{"abcdefghij", "abcd****"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := MaskToken(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("MaskToken(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTargetHost(t *testing.T) {
|
||||
// Allowed hosts
|
||||
allowed := []string{
|
||||
"https://api.dingtalk.com/v1.0/contact/users/me",
|
||||
"https://oapi.dingtalk.com/topapi/v2/user/get",
|
||||
"https://API.DINGTALK.COM/v1.0/test",
|
||||
"https://OAPI.DINGTALK.COM/topapi/test",
|
||||
}
|
||||
for _, u := range allowed {
|
||||
if err := ValidateTargetHost(u); err != nil {
|
||||
t.Errorf("ValidateTargetHost(%q) unexpected error: %v", u, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Blocked hosts
|
||||
blocked := []string{
|
||||
"https://oapi.dingtalk.fakedomain.com/topapi/v2/user/get",
|
||||
"https://fake.com/v1.0/test",
|
||||
"https://api.dingtalk.com.evil.com/v1.0/test",
|
||||
"https://evil.com/redirect?url=https://api.dingtalk.com",
|
||||
"http://localhost:8080/v1.0/test",
|
||||
"https://dingtalk.com/v1.0/test",
|
||||
}
|
||||
for _, u := range blocked {
|
||||
if err := ValidateTargetHost(u); err == nil {
|
||||
t.Errorf("ValidateTargetHost(%q) expected error, got nil", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -68,16 +68,21 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
Long: `登录钉钉并获取认证凭证。
|
||||
|
||||
支持的登录方式:
|
||||
- OAuth 设备流 (默认): 通过钉钉扫码授权登录
|
||||
- 直接提供 Token: 通过 --token 参数传入已有 token
|
||||
- OAuth Loopback 流 (默认): 本机自动起 127.0.0.1 监听接收回调,浏览器授权后自动完成
|
||||
- OAuth 设备流 (--device): 显示 user_code + 短 URL,适合 SSH 远程 / 容器 / 无头环境
|
||||
- 直接提供 Token (--token): 跳过授权,使用已有 token
|
||||
|
||||
不支持的登录方式:
|
||||
- 邮箱/密码登录
|
||||
- 手机号/验证码登录
|
||||
- 应用凭证 (AppKey/AppSecret) 直接登录
|
||||
|
||||
注意: SSH 远程或无头环境(无本地浏览器可访问远端的 127.0.0.1)请使用 --device,
|
||||
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
|
||||
|
||||
示例:
|
||||
dws auth login # 扫码登录
|
||||
dws auth login # 本机扫码登录 (loopback 流)
|
||||
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
|
||||
dws auth login --force # 强制重新登录 (忽略缓存 token)
|
||||
dws auth login --token xxx # 使用指定 token`,
|
||||
DisableAutoGenTag: true,
|
||||
@@ -198,9 +203,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)
|
||||
|
||||
@@ -26,7 +26,9 @@ import (
|
||||
)
|
||||
|
||||
func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
// Cleanup keychain after test
|
||||
// Isolate keychain storage to a per-test directory so the saved
|
||||
// token can't leak into other test packages running in parallel.
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
t.Cleanup(func() {
|
||||
_ = keychain.Remove(keychain.Service, keychain.AccountToken)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// authRetryingKey marks a context that has already attempted one
|
||||
// AuthRefreshRequired-driven retry of the current invocation. The runner uses
|
||||
// this to refuse a second refresh+retry pass and surface the original cause
|
||||
// to the user instead.
|
||||
type authRetryingKeyType struct{}
|
||||
|
||||
var authRetryingKey = authRetryingKeyType{}
|
||||
|
||||
// IsAuthRetrying reports whether the current context is already inside an
|
||||
// AuthRefreshRequired retry. Mirrors IsPatRetrying.
|
||||
func IsAuthRetrying(ctx context.Context) bool {
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
v, _ := ctx.Value(authRetryingKey).(bool)
|
||||
return v
|
||||
}
|
||||
+163
-16
@@ -14,10 +14,13 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
@@ -38,6 +41,57 @@ var legacyDirectRuntimeAliases = map[string]string{
|
||||
"dingtalk-ai-sincere-hire": "ai-sincere-hire",
|
||||
}
|
||||
|
||||
const (
|
||||
defaultPATProductID = "pat"
|
||||
defaultPATDisplayName = "行为授权"
|
||||
defaultPATServerID = "abc3c880fb90f04b52d1426aaf093766e5fc9ec38411688cbb74df42a584d374"
|
||||
)
|
||||
|
||||
func defaultPATServerDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Key: defaultPATProductID,
|
||||
DisplayName: defaultPATDisplayName,
|
||||
Endpoint: defaultPATMCPEndpoint(),
|
||||
CLI: market.CLIOverlay{
|
||||
ID: defaultPATProductID,
|
||||
Command: defaultPATProductID,
|
||||
Prefixes: []string{defaultPATProductID},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func defaultPATMCPEndpoint() string {
|
||||
return defaultPATGatewayBaseURL() + "/server/" + defaultPATServerID
|
||||
}
|
||||
|
||||
func defaultPATGatewayBaseURL() string {
|
||||
raw := strings.TrimSpace(authpkg.GetMCPBaseURL())
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return strings.TrimRight(raw, "/")
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
switch {
|
||||
case host == "mcp.dingtalk.com":
|
||||
host = "mcp-gw.dingtalk.com"
|
||||
case strings.HasPrefix(host, "pre-mcp."):
|
||||
host = strings.Replace(host, "pre-mcp.", "pre-mcp-gw.", 1)
|
||||
case strings.HasPrefix(host, "mcp."):
|
||||
host = strings.Replace(host, "mcp.", "mcp-gw.", 1)
|
||||
}
|
||||
|
||||
if port := parsed.Port(); port != "" {
|
||||
parsed.Host = net.JoinHostPort(host, port)
|
||||
} else {
|
||||
parsed.Host = host
|
||||
}
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return strings.TrimRight(parsed.String(), "/")
|
||||
}
|
||||
|
||||
// SetDynamicServers injects server data discovered from servers.json.
|
||||
// All product endpoints are resolved dynamically from this data.
|
||||
func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
@@ -48,6 +102,7 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
products := make(map[string]bool)
|
||||
aliases := make(map[string]string)
|
||||
toolEndpoints := make(map[string]string)
|
||||
registerDynamicServer(defaultPATServerDescriptor(), endpoints, products, aliases, toolEndpoints)
|
||||
for _, server := range servers {
|
||||
if server.CLI.Skip {
|
||||
continue
|
||||
@@ -80,11 +135,19 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName := range server.CLI.ToolOverrides {
|
||||
for toolName, override := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName != "" {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
// Leaves with serverOverride are routed to a different server's
|
||||
// endpoint (e.g. chat's "search_my_robots" → bot). Registering
|
||||
// them here would overwrite the real owner's tool → endpoint
|
||||
// mapping and send the invocation to the wrong MCP URL.
|
||||
if strings.TrimSpace(override.ServerOverride) != "" {
|
||||
continue
|
||||
}
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,6 +157,47 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
dynamicToolEndpoints = toolEndpoints
|
||||
}
|
||||
|
||||
func registerDynamicServer(server market.ServerDescriptor, endpoints map[string]string, products map[string]bool, aliases map[string]string, toolEndpoints map[string]string) {
|
||||
if server.CLI.Skip {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(server.CLI.ID)
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
if id != "" && endpoint != "" {
|
||||
endpoints[id] = endpoint
|
||||
products[id] = true
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
endpoints[cmd] = endpoint
|
||||
products[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias != "" && endpoint != "" {
|
||||
endpoints[alias] = endpoint
|
||||
products[alias] = true
|
||||
// Build alias -> CLI.ID mapping.
|
||||
aliases[alias] = id
|
||||
}
|
||||
}
|
||||
// Build tool -> endpoint mapping from CLI tools and overrides.
|
||||
if endpoint != "" {
|
||||
for _, tool := range server.CLI.Tools {
|
||||
toolName := strings.TrimSpace(tool.Name)
|
||||
if toolName != "" {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName != "" {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldUseDirectRuntime(invocation executor.Invocation) bool {
|
||||
if strings.TrimSpace(os.Getenv(cli.CatalogFixtureEnv)) != "" {
|
||||
return false
|
||||
@@ -106,6 +210,25 @@ func shouldUseDirectRuntime(invocation executor.Invocation) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// directRuntimeToolEndpoint returns the MCP endpoint owned by the server
|
||||
// whose toolOverrides registered this tool name. Used to correct catalog
|
||||
// lookups when two envelope servers share the same cli.command and the
|
||||
// per-product endpoint map collides (see runner.go cross-check).
|
||||
func directRuntimeToolEndpoint(toolName string) (string, bool) {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
return "", false
|
||||
}
|
||||
dynamicMu.RLock()
|
||||
te := dynamicToolEndpoints
|
||||
dynamicMu.RUnlock()
|
||||
if te == nil {
|
||||
return "", false
|
||||
}
|
||||
endpoint, ok := te[toolName]
|
||||
return endpoint, ok && strings.TrimSpace(endpoint) != ""
|
||||
}
|
||||
|
||||
func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
// Priority 0: env-var override always wins (DINGTALK_<PRODUCT>_MCP_URL).
|
||||
normalized := normalizeDirectRuntimeProductID(productID)
|
||||
@@ -123,14 +246,11 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
te := dynamicToolEndpoints
|
||||
dynamicMu.RUnlock()
|
||||
|
||||
// Priority 1: tool-level endpoint (resolves multi-endpoint products).
|
||||
if tool := strings.TrimSpace(toolName); tool != "" && te != nil {
|
||||
if endpoint, ok := te[tool]; ok {
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: product-level endpoint.
|
||||
// Priority 1: product-level endpoint.
|
||||
// When the caller already knows the productID (e.g. "drive"), the product
|
||||
// endpoint is authoritative. This prevents cross-product tool name
|
||||
// collisions (e.g. both "drive" and "doc" register "create_folder") from
|
||||
// routing the request to the wrong MCP server. See issue #219.
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
@@ -141,6 +261,24 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: tool-level endpoint (fallback for unknown productID).
|
||||
// This path is used when the caller does not know the productID but has a
|
||||
// tool name, e.g. in helper invocations or plugin routes where only the
|
||||
// tool name is available.
|
||||
if tool := strings.TrimSpace(toolName); tool != "" && te != nil {
|
||||
if endpoint, ok := te[tool]; ok {
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: built-in PAT fallback for cold-start paths that run before
|
||||
// discovery/plugin registration has populated the dynamic registry.
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if candidate == defaultPATProductID {
|
||||
return defaultPATMCPEndpoint(), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -150,7 +288,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
|
||||
}
|
||||
@@ -190,7 +329,9 @@ func AppendDynamicServer(server market.ServerDescriptor) {
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
dynamicEndpoints[cmd] = endpoint
|
||||
if _, exists := dynamicEndpoints[cmd]; !exists {
|
||||
dynamicEndpoints[cmd] = endpoint
|
||||
}
|
||||
dynamicProducts[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
@@ -208,11 +349,17 @@ func AppendDynamicServer(server market.ServerDescriptor) {
|
||||
dynamicToolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName := range server.CLI.ToolOverrides {
|
||||
for toolName, override := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName != "" {
|
||||
dynamicToolEndpoints[toolName] = endpoint
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
// Leaves with serverOverride are routed to a different server's
|
||||
// endpoint; skip to avoid overwriting the real owner's mapping.
|
||||
if strings.TrimSpace(override.ServerOverride) != "" {
|
||||
continue
|
||||
}
|
||||
dynamicToolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
)
|
||||
|
||||
// Regression for the chat/bot tool routing bug: when the `chat` envelope
|
||||
// declares toolOverrides with `serverOverride: "bot"` (e.g. `search_my_robots`,
|
||||
// `send_message_by_custom_robot`), those tool names must NOT be registered
|
||||
// into `dynamicToolEndpoints` pointing at chat's endpoint. Otherwise the
|
||||
// tool-level Priority 1 lookup in `directRuntimeEndpoint` returns chat's URL
|
||||
// even when the invocation's CanonicalProduct is "bot", causing the Portal to
|
||||
// respond with `PARAM_ERROR - 未找到指定工具` because chat's mcpId has no such
|
||||
// tool.
|
||||
//
|
||||
// Owner (bot envelope) still registers the tool (no serverOverride on the bot
|
||||
// side), so product-level and tool-level lookups both resolve correctly.
|
||||
|
||||
const (
|
||||
testBotEndpoint = "https://pre-mcp-gw.dingtalk.com/server/4717d5cbb92ecdebd89c174e4331dc17207208a97622e2004cac49c0fbedc9d1"
|
||||
testChatEndpoint = "https://pre-mcp-gw.dingtalk.com/server/0a1609437385696b77fc4771c3ddaf5656b487f809966c0cc8d4755e7b1d3b74"
|
||||
)
|
||||
|
||||
// botDescriptor returns a minimal `bot` server descriptor that owns the
|
||||
// `search_my_robots` + `send_message_by_custom_robot` tools (no
|
||||
// serverOverride — bot is the real owner).
|
||||
func botDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testBotEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "bot",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"search_my_robots": {CLIName: "search"},
|
||||
"send_message_by_custom_robot": {CLIName: "send-by-webhook"},
|
||||
"add_robot_to_group": {CLIName: "add-bot"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// chatDescriptor returns a minimal `chat` server descriptor whose
|
||||
// toolOverrides include bot-owned tools via `serverOverride: "bot"`, plus a
|
||||
// chat-native tool (`search_groups_by_keyword`) that must remain routed to
|
||||
// chat's endpoint.
|
||||
func chatDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testChatEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "chat",
|
||||
Command: "chat",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"search_groups_by_keyword": {CLIName: "search"},
|
||||
"search_my_robots": {
|
||||
CLIName: "search",
|
||||
ServerOverride: "bot",
|
||||
},
|
||||
"send_message_by_custom_robot": {
|
||||
CLIName: "send-by-webhook",
|
||||
ServerOverride: "bot",
|
||||
},
|
||||
"add_robot_to_group": {
|
||||
CLIName: "add-bot",
|
||||
ServerOverride: "bot",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// withCleanDynamicRegistry snapshots and restores the package-level dynamic
|
||||
// registries so parallel/other tests aren't affected by this case's mutations.
|
||||
func withCleanDynamicRegistry(t *testing.T) {
|
||||
t.Helper()
|
||||
dynamicMu.Lock()
|
||||
prev := struct {
|
||||
endpoints map[string]string
|
||||
products map[string]bool
|
||||
aliases map[string]string
|
||||
toolEndpoints map[string]string
|
||||
}{dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints}
|
||||
dynamicEndpoints = nil
|
||||
dynamicProducts = nil
|
||||
dynamicAliases = nil
|
||||
dynamicToolEndpoints = nil
|
||||
dynamicMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
dynamicMu.Lock()
|
||||
dynamicEndpoints = prev.endpoints
|
||||
dynamicProducts = prev.products
|
||||
dynamicAliases = prev.aliases
|
||||
dynamicToolEndpoints = prev.toolEndpoints
|
||||
dynamicMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func assertEndpoint(t *testing.T, productID, toolName, want string) {
|
||||
t.Helper()
|
||||
got, ok := directRuntimeEndpoint(productID, toolName)
|
||||
if !ok {
|
||||
t.Fatalf("directRuntimeEndpoint(%q, %q) returned ok=false", productID, toolName)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("directRuntimeEndpoint(%q, %q) = %q, want %q", productID, toolName, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetDynamicServers_ServerOverrideDoesNotHijackToolEndpoint verifies that
|
||||
// chat's serverOverride entries cannot steal bot-owned tool routes, regardless
|
||||
// of registration order.
|
||||
func TestSetDynamicServers_ServerOverrideDoesNotHijackToolEndpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
servers []market.ServerDescriptor
|
||||
}{
|
||||
{
|
||||
name: "bot first, chat second",
|
||||
servers: []market.ServerDescriptor{botDescriptor(), chatDescriptor()},
|
||||
},
|
||||
{
|
||||
name: "chat first, bot second",
|
||||
servers: []market.ServerDescriptor{chatDescriptor(), botDescriptor()},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
SetDynamicServers(tc.servers)
|
||||
|
||||
// Bot-owned tools must route to bot's endpoint even though chat
|
||||
// declares toolOverrides for them (with serverOverride="bot").
|
||||
assertEndpoint(t, "bot", "search_my_robots", testBotEndpoint)
|
||||
assertEndpoint(t, "bot", "send_message_by_custom_robot", testBotEndpoint)
|
||||
assertEndpoint(t, "bot", "add_robot_to_group", testBotEndpoint)
|
||||
|
||||
// Chat-native tools must still route to chat.
|
||||
assertEndpoint(t, "chat", "search_groups_by_keyword", testChatEndpoint)
|
||||
|
||||
// Product-level fallback for bot (no tool name) must also return
|
||||
// bot's endpoint.
|
||||
assertEndpoint(t, "bot", "", testBotEndpoint)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint exercises
|
||||
// the plugin-injection path (`AppendDynamicServer`) which has the same
|
||||
// `toolOverrides` registration loop as `SetDynamicServers`. Chat's
|
||||
// serverOverride entries must not overwrite bot's tool → endpoint mapping.
|
||||
func TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint(t *testing.T) {
|
||||
orders := [][]market.ServerDescriptor{
|
||||
{botDescriptor(), chatDescriptor()},
|
||||
{chatDescriptor(), botDescriptor()},
|
||||
}
|
||||
|
||||
for _, servers := range orders {
|
||||
t.Run("", func(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
for _, s := range servers {
|
||||
AppendDynamicServer(s)
|
||||
}
|
||||
|
||||
assertEndpoint(t, "bot", "search_my_robots", testBotEndpoint)
|
||||
assertEndpoint(t, "bot", "send_message_by_custom_robot", testBotEndpoint)
|
||||
assertEndpoint(t, "chat", "search_groups_by_keyword", testChatEndpoint)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Issue #219 regression tests: cross-product tool name collision ---
|
||||
//
|
||||
// When two different products register tools with the same name (e.g. drive
|
||||
// and doc both have "create_folder"), the product-level endpoint must win
|
||||
// when the caller already knows the productID. Otherwise the tool-level map
|
||||
// (last-writer-wins) routes the invocation to the wrong MCP server.
|
||||
|
||||
const (
|
||||
testDriveEndpoint = "https://mcp-gw.dingtalk.com/server/drive-hash"
|
||||
testDocEndpoint = "https://mcp-gw.dingtalk.com/server/doc-hash"
|
||||
)
|
||||
|
||||
func driveDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testDriveEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "drive",
|
||||
Command: "drive",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"create_folder": {CLIName: "mkdir"},
|
||||
"list_files": {CLIName: "list"},
|
||||
"download_file": {CLIName: "download"},
|
||||
"get_upload_info": {CLIName: "upload-info"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func docDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testDocEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "doc",
|
||||
Command: "doc",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"create_folder": {CLIName: "create", Group: "folder"},
|
||||
"download_file": {CLIName: "download"},
|
||||
"search_documents": {CLIName: "search"},
|
||||
"list_nodes": {CLIName: "list"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel verifies
|
||||
// that when productID is known and has a registered endpoint, the product-level
|
||||
// endpoint is used even if the tool-level map points to a different server
|
||||
// (due to same-name tool collision). This is the core fix for issue #219.
|
||||
func TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
servers []market.ServerDescriptor
|
||||
}{
|
||||
{
|
||||
name: "drive first, doc second",
|
||||
servers: []market.ServerDescriptor{driveDescriptor(), docDescriptor()},
|
||||
},
|
||||
{
|
||||
name: "doc first, drive second",
|
||||
servers: []market.ServerDescriptor{docDescriptor(), driveDescriptor()},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
SetDynamicServers(tc.servers)
|
||||
|
||||
// Drive tools must always route to drive's endpoint regardless of
|
||||
// registration order — productID "drive" is known.
|
||||
assertEndpoint(t, "drive", "create_folder", testDriveEndpoint)
|
||||
assertEndpoint(t, "drive", "download_file", testDriveEndpoint)
|
||||
assertEndpoint(t, "drive", "list_files", testDriveEndpoint)
|
||||
assertEndpoint(t, "drive", "get_upload_info", testDriveEndpoint)
|
||||
|
||||
// Doc tools must always route to doc's endpoint.
|
||||
assertEndpoint(t, "doc", "create_folder", testDocEndpoint)
|
||||
assertEndpoint(t, "doc", "download_file", testDocEndpoint)
|
||||
assertEndpoint(t, "doc", "search_documents", testDocEndpoint)
|
||||
assertEndpoint(t, "doc", "list_nodes", testDocEndpoint)
|
||||
|
||||
// Product-level fallback (no tool name) still works.
|
||||
assertEndpoint(t, "drive", "", testDriveEndpoint)
|
||||
assertEndpoint(t, "doc", "", testDocEndpoint)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Command field first-writer-wins regression test ---
|
||||
//
|
||||
// When two plugins declare the same CLI.Command but different CLI.ID values,
|
||||
// AppendDynamicServer must NOT let the second registration overwrite the
|
||||
// command → endpoint mapping established by the first. The fix uses a simple
|
||||
// "if not exists" guard on dynamicEndpoints[cmd].
|
||||
|
||||
const (
|
||||
testFirstEndpoint = "https://mcp-gw.dingtalk.com/server/first-plugin-hash"
|
||||
testSecondEndpoint = "https://mcp-gw.dingtalk.com/server/second-plugin-hash"
|
||||
)
|
||||
|
||||
func firstPluginDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testFirstEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "plugin-alpha",
|
||||
Command: "shared-cmd",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func secondPluginDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testSecondEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "plugin-beta",
|
||||
Command: "shared-cmd",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendDynamicServer_CommandEndpointFirstWriterWins verifies that when
|
||||
// two plugins declare the same Command (but different IDs), only the first
|
||||
// registration takes effect for the command → endpoint mapping. The second
|
||||
// plugin's own id-based endpoint is unaffected.
|
||||
func TestAppendDynamicServer_CommandEndpointFirstWriterWins(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
|
||||
AppendDynamicServer(firstPluginDescriptor())
|
||||
AppendDynamicServer(secondPluginDescriptor())
|
||||
|
||||
// The command "shared-cmd" must resolve to the first plugin's endpoint.
|
||||
assertEndpoint(t, "shared-cmd", "", testFirstEndpoint)
|
||||
|
||||
// Each plugin's own id-based endpoint is always unconditionally written.
|
||||
assertEndpoint(t, "plugin-alpha", "", testFirstEndpoint)
|
||||
assertEndpoint(t, "plugin-beta", "", testSecondEndpoint)
|
||||
|
||||
// Command must appear in dynamicProducts (discovery) regardless.
|
||||
ids := DirectRuntimeProductIDs()
|
||||
if !ids["shared-cmd"] {
|
||||
t.Fatal("shared-cmd not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
if !ids["plugin-alpha"] {
|
||||
t.Fatal("plugin-alpha not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
if !ids["plugin-beta"] {
|
||||
t.Fatal("plugin-beta not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown verifies that
|
||||
// tool-level routing still works as a fallback when productID is empty or has
|
||||
// no registered endpoint (the original design intent for tool-level Priority 1).
|
||||
func TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
SetDynamicServers([]market.ServerDescriptor{driveDescriptor(), docDescriptor()})
|
||||
|
||||
// When productID is empty, tool-level endpoint is the only option.
|
||||
// The actual endpoint depends on registration order (last-writer-wins),
|
||||
// but the lookup must succeed.
|
||||
endpoint, ok := directRuntimeEndpoint("", "create_folder")
|
||||
if !ok {
|
||||
t.Fatal("directRuntimeEndpoint(\"\", \"create_folder\") returned ok=false, want ok=true")
|
||||
}
|
||||
if endpoint != testDriveEndpoint && endpoint != testDocEndpoint {
|
||||
t.Fatalf("directRuntimeEndpoint(\"\", \"create_folder\") = %q, want one of drive/doc endpoints", endpoint)
|
||||
}
|
||||
|
||||
// Unique tools (no collision) still resolve via tool-level.
|
||||
assertEndpoint(t, "", "search_documents", testDocEndpoint)
|
||||
assertEndpoint(t, "", "get_upload_info", testDriveEndpoint)
|
||||
}
|
||||
@@ -1,6 +1,74 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
)
|
||||
|
||||
func TestDefaultPATServerDescriptorUsesBehaviorAuthorizationName(t *testing.T) {
|
||||
server := defaultPATServerDescriptor()
|
||||
if server.CLI.ID != "pat" {
|
||||
t.Fatalf("default PAT server id = %q, want pat", server.CLI.ID)
|
||||
}
|
||||
if server.DisplayName != "行为授权" {
|
||||
t.Fatalf("default PAT server display name = %q, want 行为授权", server.DisplayName)
|
||||
}
|
||||
if server.Endpoint != defaultPATMCPEndpoint() {
|
||||
t.Fatalf("default PAT server endpoint = %q, want %q", server.Endpoint, defaultPATMCPEndpoint())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectRuntimeProductIDsIncludesDefaultPAT(t *testing.T) {
|
||||
dynamicMu.Lock()
|
||||
previousProducts := dynamicProducts
|
||||
dynamicProducts = nil
|
||||
dynamicMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
dynamicMu.Lock()
|
||||
dynamicProducts = previousProducts
|
||||
dynamicMu.Unlock()
|
||||
})
|
||||
|
||||
ids := DirectRuntimeProductIDs()
|
||||
if !ids["pat"] {
|
||||
t.Fatalf("DirectRuntimeProductIDs() missing default pat product: %#v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DefaultPATFallbackWhenRegistryMissing(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
assertEndpoint(t, "pat", "", defaultPATMCPEndpoint())
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DefaultPATFallbackUsesConfiguredMCPBaseURL(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "mcp_url"), []byte("http://127.0.0.1:54321/base"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", tmpDir)
|
||||
|
||||
assertEndpoint(t, "pat", "", "http://127.0.0.1:54321/base/server/"+defaultPATServerID)
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_PATDiscoveryOverrideWinsOverBuiltInFallback(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
customEndpoint := "https://example.com/server/custom-pat"
|
||||
SetDynamicServers([]market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: customEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "pat",
|
||||
Command: "pat",
|
||||
},
|
||||
},
|
||||
})
|
||||
assertEndpoint(t, "pat", "", customEndpoint)
|
||||
}
|
||||
|
||||
func TestNormalizeDirectRuntimeProductIDPreservesLegacyHiddenVendorRouting(t *testing.T) {
|
||||
dynamicMu.Lock()
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
const (
|
||||
docProductID = "doc"
|
||||
docDownloadFileTool = "download_file"
|
||||
docGetDocumentInfoTool = "get_document_info"
|
||||
docAXLSExtension = "axls"
|
||||
)
|
||||
|
||||
func (r *runtimeRunner) preflightDocDownload(ctx context.Context, tc *transport.Client, endpoint string, invocation executor.Invocation) error {
|
||||
if !isDocDownloadInvocation(invocation) {
|
||||
return nil
|
||||
}
|
||||
nodeID := docDownloadNodeID(invocation.Params)
|
||||
if nodeID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
preflightStart := time.Now()
|
||||
info, err := tc.CallTool(ctx, endpoint, docGetDocumentInfoTool, map[string]any{"nodeId": nodeID})
|
||||
RecordTiming(ctx, "doc_download_preflight", time.Since(preflightStart))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if classify := edition.Get().ClassifyToolResult; classify != nil {
|
||||
if err := classify(info.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if patCheck := apperrors.ClassifyPatAuthCheck(info.Content); patCheck != nil {
|
||||
return patCheck
|
||||
}
|
||||
if info.IsError {
|
||||
return apperrors.NewAPI(
|
||||
extractMCPErrorMessage(info),
|
||||
apperrors.WithOperation("doc.get_document_info"),
|
||||
apperrors.WithReason("doc_download_preflight_failed"),
|
||||
apperrors.WithServerKey(docProductID),
|
||||
apperrors.WithHint("doc download 必须先确认节点类型,避免对不支持下载的在线表格触发 drive:download 授权。"),
|
||||
apperrors.WithActions("dws doc info --node <nodeId>"),
|
||||
)
|
||||
}
|
||||
if bizErr := detectBusinessError(info.Content); bizErr != "" {
|
||||
return apperrors.NewAPI(
|
||||
bizErr,
|
||||
apperrors.WithOperation("doc.get_document_info"),
|
||||
apperrors.WithReason("doc_download_preflight_failed"),
|
||||
apperrors.WithServerKey(docProductID),
|
||||
apperrors.WithHint("doc download 必须先确认节点类型,避免对不支持下载的在线表格触发 drive:download 授权。"),
|
||||
apperrors.WithActions("dws doc info --node <nodeId>"),
|
||||
)
|
||||
}
|
||||
|
||||
if strings.EqualFold(documentInfoExtension(info.Content), docAXLSExtension) {
|
||||
return unsupportedAXLSDownloadError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDocDownloadInvocation(invocation executor.Invocation) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(invocation.CanonicalProduct), docProductID) &&
|
||||
strings.TrimSpace(invocation.Tool) == docDownloadFileTool
|
||||
}
|
||||
|
||||
func docDownloadNodeID(params map[string]any) string {
|
||||
for _, key := range []string{"nodeId", "node", "dentryUuid"} {
|
||||
if value, ok := params[key].(string); ok {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func unsupportedAXLSDownloadError() error {
|
||||
return apperrors.NewValidation(
|
||||
"nodeId 指向的节点是钉钉表格(extension=axls),在线表格不支持直接下载。请使用 getRange 工具获取表格数据。",
|
||||
apperrors.WithOperation("doc.download_file.preflight"),
|
||||
apperrors.WithReason("unsupported_alidoc_extension"),
|
||||
apperrors.WithServerKey(docProductID),
|
||||
apperrors.WithHint("在线表格应先用 doc info 确认 extension,再改用表格 MCP 的 get_all_sheets / get_range 读取数据。"),
|
||||
apperrors.WithActions("dws doc info --node <nodeId>", "使用表格 MCP get_all_sheets / get_range"),
|
||||
)
|
||||
}
|
||||
|
||||
func documentInfoExtension(content map[string]any) string {
|
||||
for _, path := range [][]string{
|
||||
{"result", "extension"},
|
||||
{"data", "extension"},
|
||||
{"extension"},
|
||||
} {
|
||||
if value := stringAtPath(content, path...); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stringAtPath(value any, path ...string) string {
|
||||
current := value
|
||||
for _, key := range path {
|
||||
object, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
current = object[key]
|
||||
}
|
||||
if text, ok := current.(string); ok {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestFlagErrorWithSuggestions_authStructured(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd := &cobra.Command{Use: "login", Run: func(*cobra.Command, []string) {}}
|
||||
orig := fmt.Errorf("unknown flag: --json")
|
||||
err := flagErrorWithSuggestions(cmd, orig)
|
||||
var ae *apperrors.Error
|
||||
if !stderrors.As(err, &ae) {
|
||||
t.Fatalf("want *apperrors.Error, got %T", err)
|
||||
}
|
||||
if ae.Message != orig.Error() {
|
||||
t.Fatalf("Message = %q, want %q", ae.Message, orig.Error())
|
||||
}
|
||||
if ae.Reason != "unknown_flag" {
|
||||
t.Fatalf("Reason = %q, want unknown_flag", ae.Reason)
|
||||
}
|
||||
if ae.Hint == "" || !strings.Contains(ae.Hint, "format json") {
|
||||
t.Fatalf("Hint = %q", ae.Hint)
|
||||
}
|
||||
if ae.Cause != orig {
|
||||
t.Fatalf("Cause = %v, want orig", ae.Cause)
|
||||
}
|
||||
if !stderrors.Is(err, orig) {
|
||||
t.Fatal("errors.Is(err, orig) should hold via unwrap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagErrorWithSuggestions_unknownFlagHintAndFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
|
||||
cmd.Flags().String("start", "", "begin time")
|
||||
_ = cmd.Flags().SetAnnotation("start", "x-cli-format", []string{"date-time"})
|
||||
orig := fmt.Errorf("unknown flag: --starttime1")
|
||||
err := flagErrorWithSuggestions(cmd, orig)
|
||||
var ae *apperrors.Error
|
||||
if !stderrors.As(err, &ae) {
|
||||
t.Fatalf("want *apperrors.Error, got %T", err)
|
||||
}
|
||||
if ae.Reason != "unknown_flag" {
|
||||
t.Fatalf("Reason = %q", ae.Reason)
|
||||
}
|
||||
if strings.Contains(ae.Hint, "Space required") {
|
||||
t.Fatalf("false glue must not suggest space: %q", ae.Hint)
|
||||
}
|
||||
if !strings.Contains(ae.Hint, "help") {
|
||||
t.Fatalf("expected help fallback in hint, got %q", ae.Hint)
|
||||
}
|
||||
if len(ae.AvailableFlags) != 1 || ae.AvailableFlags[0] != "start" {
|
||||
t.Fatalf("AvailableFlags = %v, want [start]", ae.AvailableFlags)
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) {
|
||||
cmd.PersistentFlags().BoolVar(&flags.Debug, "debug", false, "显示调试日志")
|
||||
cmd.PersistentFlags().BoolVar(&flags.DryRun, "dry-run", false, "预览操作内容,不实际执行")
|
||||
cmd.PersistentFlags().StringVar(&flags.Fields, "fields", "", "筛选输出字段 (逗号分隔, 如: name,id,status)")
|
||||
cmd.PersistentFlags().StringVarP(&flags.Format, "format", "f", "json", "输出格式: json|table|raw|pretty")
|
||||
cmd.PersistentFlags().StringVarP(&flags.Format, "format", "f", "json", "输出格式: json|table|raw|pretty|ndjson|csv")
|
||||
cmd.PersistentFlags().StringVar(&flags.JQ, "jq", "", "jq 表达式过滤输出 (如: '.items[] | .name')")
|
||||
cmd.PersistentFlags().BoolVar(&flags.Mock, "mock", false, "使用 Mock 数据 (开发调试用)")
|
||||
cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "", "Write command output to a file")
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
)
|
||||
|
||||
// ForceRefreshAccessToken forces a single refresh_token exchange and returns
|
||||
// the new access_token. It is intended for callers that have observed a
|
||||
// server-side rejection (HTTP 401 or business code such as
|
||||
// TOKEN_VERIFIED_FAILED) on what locally appeared to be a still-valid token.
|
||||
//
|
||||
// Steps:
|
||||
// 1. MarkAccessTokenStale rewrites ExpiresAt to a past instant so
|
||||
// OAuthProvider.GetAccessToken's fast-path will miss.
|
||||
// 2. NewOAuthProvider + GetAccessToken triggers lockedRefresh, which uses the
|
||||
// existing dual-layer lock (process + file) to serialize concurrent
|
||||
// refresh attempts across goroutines and processes.
|
||||
// 3. ResetRuntimeTokenCache clears the per-process sync.Once cache so the
|
||||
// next resolveAuthToken call re-reads from disk.
|
||||
//
|
||||
// Existing OAuthProvider.GetAccessToken behaviour is unchanged; this helper
|
||||
// is the only entry point that orchestrates "force refresh" semantics.
|
||||
func ForceRefreshAccessToken(ctx context.Context, configDir string) (string, error) {
|
||||
if strings.TrimSpace(configDir) == "" {
|
||||
return "", fmt.Errorf("config directory is empty")
|
||||
}
|
||||
if err := authpkg.MarkAccessTokenStale(configDir); err != nil {
|
||||
return "", fmt.Errorf("mark access token stale: %w", err)
|
||||
}
|
||||
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
provider := authpkg.NewOAuthProvider(configDir, disc)
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
tok, err := provider.GetAccessToken(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tok = strings.TrimSpace(tok)
|
||||
if tok == "" {
|
||||
return "", fmt.Errorf("force refresh returned empty access token")
|
||||
}
|
||||
ResetRuntimeTokenCache()
|
||||
return tok, nil
|
||||
}
|
||||
+173
-22
@@ -29,9 +29,11 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/editionmerge"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -47,12 +49,46 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
|
||||
return mergeTopLevelCommands(commands)
|
||||
}
|
||||
|
||||
var commands []*cobra.Command
|
||||
if dynamicCmds := loadDynamicCommands(ctx, runner); len(dynamicCmds) > 0 {
|
||||
commands = append(commands, dynamicCmds...)
|
||||
dynamicCmds := loadDynamicCommands(ctx, runner)
|
||||
helperCmds := helpers.NewPublicCommands(runner)
|
||||
return mergeTopLevelCommands(pickCommands(dynamicCmds, helperCmds))
|
||||
}
|
||||
|
||||
// pickCommands returns the union of dynamic and helpers commands. For
|
||||
// same-named top-level products, helper-only leaves are grafted into the
|
||||
// dynamic tree via cmdutil.MergeHardcodedLeaves so the discovery envelope
|
||||
// remains the authority for leaves it declares, while hardcoded helpers can
|
||||
// still fill gaps the envelope did not cover (e.g. `chat message send-by-bot`
|
||||
// alongside the envelope's `chat message send`).
|
||||
//
|
||||
// Why this exists: mergeTopLevelCommands below calls cobracmd.MergeCommandTree
|
||||
// on same-named top-level commands, which — at leaf conflicts — falls back to
|
||||
// "more local flags wins" via ShouldReplaceLeaf. Hardcoded helpers commands
|
||||
// typically expose more flags than the corresponding dynamic overlay leaves,
|
||||
// so a naive append would silently promote helper leaves over their dynamic
|
||||
// counterparts. MergeHardcodedLeaves avoids that by letting dynamic win every
|
||||
// leaf conflict, and only adding subtrees the dynamic side lacks.
|
||||
func pickCommands(dynamic, helpers []*cobra.Command) []*cobra.Command {
|
||||
dynByName := make(map[string]*cobra.Command, len(dynamic))
|
||||
out := make([]*cobra.Command, 0, len(dynamic)+len(helpers))
|
||||
for _, c := range dynamic {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
dynByName[c.Name()] = c
|
||||
out = append(out, c)
|
||||
}
|
||||
commands = append(commands, helpers.NewPublicCommands(runner)...)
|
||||
return mergeTopLevelCommands(commands)
|
||||
for _, h := range helpers {
|
||||
if h == nil {
|
||||
continue
|
||||
}
|
||||
if dyn := dynByName[h.Name()]; dyn != nil {
|
||||
cmdutil.MergeHardcodedLeaves(dyn, h)
|
||||
continue
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// injectStaticServers converts edition.ServerInfo entries into
|
||||
@@ -87,9 +123,55 @@ func injectStaticServers(servers []edition.ServerInfo) {
|
||||
//
|
||||
// Tests may override discoveryBaseURLOverride to redirect to a local server;
|
||||
// in that case the registry cache is always bypassed.
|
||||
// editionPartition returns the cache partition for the active edition.
|
||||
// Thin wrapper around config.EditionPartition; kept so the many existing
|
||||
// call sites in internal/app don't need to thread edition.Get() everywhere.
|
||||
func editionPartition() string {
|
||||
return config.EditionPartition(edition.Get().Name)
|
||||
}
|
||||
|
||||
// discoveryTraceEnabled reports whether the user asked for discovery-path diagnostics.
|
||||
// loadDynamicCommands runs while building the command tree, before PersistentPreRun
|
||||
// applies --debug to slog; we also accept argv --debug and DWS_PERF_DEBUG for consistency.
|
||||
func discoveryTraceEnabled() bool {
|
||||
if IsPerfDebugEnabled() {
|
||||
return true
|
||||
}
|
||||
for _, a := range os.Args[1:] {
|
||||
if a == "--debug" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func discoveryTraceServerIDs(servers []market.ServerDescriptor) []string {
|
||||
seen := make(map[string]struct{})
|
||||
for _, s := range servers {
|
||||
id := strings.TrimSpace(s.CLI.Command)
|
||||
if id == "" {
|
||||
id = strings.TrimSpace(s.CLI.ID)
|
||||
}
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for id := range seen {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Strings(out)
|
||||
const maxIDs = 48
|
||||
if len(out) > maxIDs {
|
||||
out = out[:maxIDs]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
|
||||
store := cacheStoreFromEnv()
|
||||
partition := config.DefaultPartition
|
||||
partition := editionPartition()
|
||||
|
||||
// Bypass the registry cache when a fixture override is active.
|
||||
// This ensures tests that set DWS_CATALOG_FIXTURE always get fresh
|
||||
@@ -116,27 +198,55 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
|
||||
}
|
||||
}
|
||||
|
||||
if len(servers) > 0 && discoveryTraceEnabled() {
|
||||
slog.Info("loadDynamicCommands: skipping sync discovery fetch, using registry cache",
|
||||
"partition", partition,
|
||||
"servers", len(servers),
|
||||
"registry_freshness", string(freshness))
|
||||
}
|
||||
|
||||
// Cache miss or bypassed: fetch from market API synchronously (first run only).
|
||||
if len(servers) == 0 {
|
||||
baseURL := cli.DefaultMarketBaseURL
|
||||
if discoveryBaseURLOverride != "" {
|
||||
baseURL = discoveryBaseURLOverride
|
||||
if discoveryTraceEnabled() {
|
||||
if edURL := strings.TrimSpace(edition.Get().DiscoveryURL); edURL != "" {
|
||||
slog.Info("loadDynamicCommands: sync discovery fetch", "partition", partition, "url", edURL)
|
||||
} else {
|
||||
baseURL := cli.DefaultMarketBaseURL
|
||||
if discoveryBaseURLOverride != "" {
|
||||
baseURL = discoveryBaseURLOverride
|
||||
}
|
||||
slog.Info("loadDynamicCommands: sync market catalog fetch", "partition", partition, "base_url", baseURL)
|
||||
}
|
||||
}
|
||||
fetchStart := time.Now()
|
||||
client := market.NewClient(baseURL, ipv4OnlyHTTPClient())
|
||||
resp, fetchErr := client.FetchServers(ctx, config.DefaultFetchServersLimit)
|
||||
|
||||
resp, fetchErr := fetchRegistryServers(ctx, ipv4OnlyHTTPClient())
|
||||
|
||||
RecordTiming(ctx, "market_fetch", time.Since(fetchStart))
|
||||
if fetchErr != nil {
|
||||
if discoveryTraceEnabled() {
|
||||
slog.Info("loadDynamicCommands: sync discovery fetch failed",
|
||||
"partition", partition,
|
||||
"error", fetchErr.Error())
|
||||
}
|
||||
slog.Debug("loadDynamicCommands: market API fetch failed", "error", fetchErr)
|
||||
// Degrade to stale cache if available (production only).
|
||||
if useCache && cacheErr == nil && len(snapshot.Servers) > 0 {
|
||||
slog.Debug("loadDynamicCommands: degrading to stale registry cache", "servers", len(snapshot.Servers))
|
||||
servers = snapshot.Servers
|
||||
} else {
|
||||
return nil
|
||||
// no-op: fall through to FallbackServers check below
|
||||
}
|
||||
} else {
|
||||
servers = market.NormalizeServers(resp, "market")
|
||||
if discoveryTraceEnabled() {
|
||||
slog.Info("loadDynamicCommands: sync discovery fetch ok",
|
||||
"partition", partition,
|
||||
"response_servers", len(resp.Servers),
|
||||
"metadata_count", resp.Metadata.Count,
|
||||
"normalized_servers", len(servers),
|
||||
"cli_command_ids", discoveryTraceServerIDs(servers))
|
||||
}
|
||||
// Persist fresh data (only in non-test mode).
|
||||
if useCache {
|
||||
saveStart := time.Now()
|
||||
@@ -148,9 +258,22 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
|
||||
}
|
||||
}
|
||||
|
||||
// FallbackServers: safety net when Market discovery + cache both fail.
|
||||
if len(servers) == 0 {
|
||||
if fn := edition.Get().FallbackServers; fn != nil {
|
||||
if fb := fn(); len(fb) > 0 {
|
||||
slog.Debug("loadDynamicCommands: using FallbackServers", "count", len(fb))
|
||||
descriptors := editionmerge.FallbackToDescriptors(fb)
|
||||
descriptors = editionmerge.MergeSupplement(descriptors)
|
||||
SetDynamicServers(descriptors)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Merge edition-specific supplement servers (not in Market).
|
||||
servers = editionmerge.MergeSupplement(servers)
|
||||
// Inject dynamic server data for endpoint resolution
|
||||
SetDynamicServers(servers)
|
||||
|
||||
@@ -172,7 +295,7 @@ func loadCachedDetailsFast(store *cache.Store, servers []market.ServerDescriptor
|
||||
if store == nil {
|
||||
return result
|
||||
}
|
||||
partition := config.DefaultPartition
|
||||
partition := editionPartition()
|
||||
for _, server := range servers {
|
||||
if server.DetailLocator.MCPID <= 0 {
|
||||
continue
|
||||
@@ -203,7 +326,7 @@ func fetchDetailsByServerID(ctx context.Context, client *market.Client, servers
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
partition := config.DefaultPartition
|
||||
partition := editionPartition()
|
||||
now := time.Now().UTC()
|
||||
if store != nil && store.Now != nil {
|
||||
now = store.Now().UTC()
|
||||
@@ -333,14 +456,16 @@ func DiscoveryBaseURL() string {
|
||||
return cli.DefaultMarketBaseURL
|
||||
}
|
||||
|
||||
// ipv4OnlyHTTPClient returns an HTTP client that forces IPv4 connections
|
||||
// and uses a short timeout suitable for CLI startup network requests.
|
||||
// This avoids IPv6 DNS/connect timeouts on hosts without IPv6 networking.
|
||||
func ipv4OnlyHTTPClient() *http.Client {
|
||||
// ipv4HTTPClient returns an HTTP client that forces IPv4 connections with
|
||||
// the given total request timeout. This avoids IPv6 DNS/connect timeouts on
|
||||
// hosts without IPv6 networking.
|
||||
func ipv4HTTPClient(timeout time.Duration) *http.Client {
|
||||
dialer := &net.Dialer{Timeout: 3 * time.Second}
|
||||
return &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#236).
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, "tcp4", addr)
|
||||
},
|
||||
@@ -348,6 +473,29 @@ func ipv4OnlyHTTPClient() *http.Client {
|
||||
}
|
||||
}
|
||||
|
||||
// ipv4OnlyHTTPClient returns an IPv4-forcing HTTP client with a short timeout
|
||||
// suitable for CLI startup network requests.
|
||||
func ipv4OnlyHTTPClient() *http.Client {
|
||||
return ipv4HTTPClient(5 * time.Second)
|
||||
}
|
||||
|
||||
// fetchRegistryServers performs the server-list HTTP fetch honoring the
|
||||
// active edition's DiscoveryURL override. It is the single source of truth
|
||||
// for all server-list fetches (startup, async revalidation, explicit
|
||||
// `cache refresh`); keeping the edition-URL branch in one place prevents
|
||||
// call sites from drifting out of sync.
|
||||
func fetchRegistryServers(ctx context.Context, httpClient *http.Client) (market.ListResponse, error) {
|
||||
if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" {
|
||||
client := market.NewClient("", httpClient)
|
||||
if fn := edition.Get().DiscoveryHeaders; fn != nil {
|
||||
client.Headers = fn()
|
||||
}
|
||||
return client.FetchServersFromURL(ctx, editionURL)
|
||||
}
|
||||
client := market.NewClient(DiscoveryBaseURL(), httpClient)
|
||||
return client.FetchServers(ctx, config.DefaultFetchServersLimit)
|
||||
}
|
||||
|
||||
// asyncRevalidateRegistry refreshes the registry cache in the background.
|
||||
// Uses a short timeout derived from the parent context and silently ignores
|
||||
// errors — the next CLI invocation will pick up the refreshed cache or retry.
|
||||
@@ -355,9 +503,7 @@ func asyncRevalidateRegistry(parent context.Context, store *cache.Store, partiti
|
||||
ctx, cancel := context.WithTimeout(parent, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
baseURL := DiscoveryBaseURL()
|
||||
client := market.NewClient(baseURL, ipv4OnlyHTTPClient())
|
||||
resp, err := client.FetchServers(ctx, config.DefaultFetchServersLimit)
|
||||
resp, err := fetchRegistryServers(ctx, ipv4OnlyHTTPClient())
|
||||
if err != nil {
|
||||
slog.Debug("asyncRevalidateRegistry: fetch failed", "error", err)
|
||||
return
|
||||
@@ -398,3 +544,8 @@ func mergeTopLevelCommands(commands []*cobra.Command) []*cobra.Command {
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeSupplementServers / fallbackToDescriptors have moved to
|
||||
// internal/editionmerge so that both internal/cli and internal/app can
|
||||
// apply the edition's SupplementServers / FallbackServers hooks against
|
||||
// the same discovery pipeline (command tree + runtime catalog).
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestPickCommands_DynamicWinsLeafConflicts verifies that when the discovery
|
||||
// envelope produces a dynamic leaf and a helper registers the same-named leaf,
|
||||
// the dynamic one wins — envelopes remain the runtime authority for behaviour
|
||||
// they declare. The helper subtree must not slip in via
|
||||
// mergeTopLevelCommands's LocalFlagCount-based arbitration.
|
||||
func TestPickCommands_DynamicWinsLeafConflicts(t *testing.T) {
|
||||
dynTask := &cobra.Command{Use: "task", Short: "dynamic-task", Run: func(*cobra.Command, []string) {}}
|
||||
dyn := &cobra.Command{Use: "todo", Short: "dynamic"}
|
||||
dyn.AddCommand(dynTask)
|
||||
dynamic := []*cobra.Command{dyn}
|
||||
|
||||
hlpTask := &cobra.Command{Use: "task", Short: "helper-task", Run: func(*cobra.Command, []string) {}}
|
||||
hlp := &cobra.Command{Use: "todo", Short: "helper"}
|
||||
hlp.AddCommand(hlpTask)
|
||||
helpers := []*cobra.Command{hlp}
|
||||
|
||||
got := pickCommands(dynamic, helpers)
|
||||
|
||||
if len(got) != 1 || got[0] != dyn {
|
||||
t.Fatalf("pickCommands returned %v, want [dyn]", got)
|
||||
}
|
||||
// The dynamic leaf must still be the one we find under the top-level name.
|
||||
var found *cobra.Command
|
||||
for _, c := range got[0].Commands() {
|
||||
if c.Name() == "task" {
|
||||
found = c
|
||||
}
|
||||
}
|
||||
if found != dynTask {
|
||||
t.Fatalf("leaf conflict resolved to helper; want dynamic to win")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_HelperOnlyLeavesAreGrafted verifies that when a helper
|
||||
// registers siblings the discovery envelope did NOT declare (e.g.
|
||||
// `chat message send-by-bot`, `chat message recall-by-bot` next to the
|
||||
// envelope's `chat message send`), those helper-only leaves are grafted into
|
||||
// the dynamic subtree instead of being dropped. This is a regression guard:
|
||||
// prior to this fix, pickCommands silently dropped the entire helper subtree
|
||||
// whenever the top-level product name collided, which disappeared every
|
||||
// helper-only leaf the envelope didn't cover.
|
||||
func TestPickCommands_HelperOnlyLeavesAreGrafted(t *testing.T) {
|
||||
dynMessage := &cobra.Command{Use: "message"}
|
||||
dynMessage.AddCommand(&cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}})
|
||||
dyn := &cobra.Command{Use: "chat"}
|
||||
dyn.AddCommand(dynMessage)
|
||||
dynamic := []*cobra.Command{dyn}
|
||||
|
||||
helperOnlyLeaf := &cobra.Command{Use: "send-by-bot", Run: func(*cobra.Command, []string) {}}
|
||||
hlpMessage := &cobra.Command{Use: "message"}
|
||||
hlpMessage.AddCommand(helperOnlyLeaf)
|
||||
hlp := &cobra.Command{Use: "chat"}
|
||||
hlp.AddCommand(hlpMessage)
|
||||
helpers := []*cobra.Command{hlp}
|
||||
|
||||
got := pickCommands(dynamic, helpers)
|
||||
|
||||
if len(got) != 1 || got[0] != dyn {
|
||||
t.Fatalf("pickCommands returned %v, want [dyn]", got)
|
||||
}
|
||||
var grafted *cobra.Command
|
||||
for _, child := range dynMessage.Commands() {
|
||||
if child.Name() == "send-by-bot" {
|
||||
grafted = child
|
||||
}
|
||||
}
|
||||
if grafted == nil {
|
||||
t.Fatalf("helper-only leaf send-by-bot was not grafted into dynamic.chat.message")
|
||||
}
|
||||
if grafted != helperOnlyLeaf {
|
||||
t.Fatalf("grafted leaf identity differs from helper-registered leaf")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_HelpersFillUncoveredProducts verifies that helpers whose
|
||||
// names are NOT in the dynamic set are preserved — the dynamic overlay only
|
||||
// shadows products it actually covers.
|
||||
func TestPickCommands_HelpersFillUncoveredProducts(t *testing.T) {
|
||||
dyn := &cobra.Command{Use: "todo"}
|
||||
dynamic := []*cobra.Command{dyn}
|
||||
|
||||
todoHelper := &cobra.Command{Use: "todo"}
|
||||
attendanceHelper := &cobra.Command{Use: "attendance"}
|
||||
chatHelper := &cobra.Command{Use: "chat"}
|
||||
helpers := []*cobra.Command{todoHelper, attendanceHelper, chatHelper}
|
||||
|
||||
got := pickCommands(dynamic, helpers)
|
||||
|
||||
names := make(map[string]*cobra.Command, len(got))
|
||||
for _, c := range got {
|
||||
names[c.Name()] = c
|
||||
}
|
||||
if names["todo"] != dyn {
|
||||
t.Fatalf("todo = %v, want dynamic", names["todo"])
|
||||
}
|
||||
if names["attendance"] != attendanceHelper {
|
||||
t.Fatalf("attendance not preserved from helpers")
|
||||
}
|
||||
if names["chat"] != chatHelper {
|
||||
t.Fatalf("chat not preserved from helpers")
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d commands, want 3 (todo+attendance+chat)", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_EmptyDynamicPreservesHelpers verifies the degenerate case:
|
||||
// when discovery returns nothing, helpers are the sole source of truth — the
|
||||
// behaviour must be identical to the pre-refactor append-all code path.
|
||||
func TestPickCommands_EmptyDynamicPreservesHelpers(t *testing.T) {
|
||||
todoHelper := &cobra.Command{Use: "todo"}
|
||||
chatHelper := &cobra.Command{Use: "chat"}
|
||||
helpers := []*cobra.Command{todoHelper, chatHelper}
|
||||
|
||||
got := pickCommands(nil, helpers)
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d commands, want 2", len(got))
|
||||
}
|
||||
if got[0] != todoHelper || got[1] != chatHelper {
|
||||
t.Fatalf("pickCommands changed helpers order or identity")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_HelperGroupShadowsDynamicLeaf simulates the issue #164
|
||||
// shape mismatch: the discovery envelope publishes `chat group members` as
|
||||
// a LEAF (the get_group_members tool exposed at that CLI path), while the
|
||||
// hardcoded helper has restructured `members` into a GROUP container with
|
||||
// `list / add / remove / add-bot` subcommands. The helper group carries the
|
||||
// preferLegacyLeaf priority annotation, so it must replace the dynamic leaf
|
||||
// and surface its subtree — otherwise `dws chat group members list` is
|
||||
// unreachable and the user-visible regression in #164 stays.
|
||||
func TestPickCommands_HelperGroupShadowsDynamicLeaf(t *testing.T) {
|
||||
dynMembers := &cobra.Command{Use: "members", Run: func(*cobra.Command, []string) {}}
|
||||
dynMembers.Flags().String("id", "", "")
|
||||
dynGroup := &cobra.Command{Use: "group"}
|
||||
dynGroup.AddCommand(dynMembers)
|
||||
dyn := &cobra.Command{Use: "chat"}
|
||||
dyn.AddCommand(dynGroup)
|
||||
|
||||
hlpList := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
|
||||
hlpList.Flags().String("id", "", "")
|
||||
hlpAdd := &cobra.Command{Use: "add", Run: func(*cobra.Command, []string) {}}
|
||||
hlpRemove := &cobra.Command{Use: "remove", Run: func(*cobra.Command, []string) {}}
|
||||
hlpMembers := &cobra.Command{Use: "members"}
|
||||
hlpMembers.AddCommand(hlpList, hlpAdd, hlpRemove)
|
||||
cobracmd.SetOverridePriority(hlpMembers, 100)
|
||||
hlpGroup := &cobra.Command{Use: "group"}
|
||||
hlpGroup.AddCommand(hlpMembers)
|
||||
hlp := &cobra.Command{Use: "chat"}
|
||||
hlp.AddCommand(hlpGroup)
|
||||
|
||||
got := pickCommands([]*cobra.Command{dyn}, []*cobra.Command{hlp})
|
||||
if len(got) != 1 || got[0] != dyn {
|
||||
t.Fatalf("got %v, want [dyn]", got)
|
||||
}
|
||||
|
||||
// Locate the (potentially replaced) members node under chat.group.
|
||||
var members *cobra.Command
|
||||
for _, c := range dynGroup.Commands() {
|
||||
if c.Name() == "members" {
|
||||
members = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if members == nil {
|
||||
t.Fatalf("members node missing under dyn.chat.group after merge")
|
||||
}
|
||||
|
||||
want := map[string]bool{"list": false, "add": false, "remove": false}
|
||||
for _, sub := range members.Commands() {
|
||||
if _, ok := want[sub.Name()]; ok {
|
||||
want[sub.Name()] = true
|
||||
}
|
||||
}
|
||||
for name, seen := range want {
|
||||
if !seen {
|
||||
t.Errorf("expected `chat group members %s` after merge, missing", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_NilsAreSkipped guards against nil entries sneaking in from
|
||||
// a misbehaving factory.
|
||||
func TestPickCommands_NilsAreSkipped(t *testing.T) {
|
||||
dyn := &cobra.Command{Use: "todo"}
|
||||
hlp := &cobra.Command{Use: "chat"}
|
||||
|
||||
got := pickCommands([]*cobra.Command{nil, dyn}, []*cobra.Command{nil, hlp})
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d commands, want 2 (nils filtered)", len(got))
|
||||
}
|
||||
if got[0] != dyn || got[1] != hlp {
|
||||
t.Fatalf("unexpected ordering or identity after nil filter")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// TestEditionPartition_SingleSourceOfTruth is the regression test that
|
||||
// specifically targets the original bug: internal/app.loadDynamicCommands
|
||||
// was computing its partition one way (editionPartition() →
|
||||
// "wukong/default") while internal/cli.EnvironmentLoader was hardcoding
|
||||
// config.DefaultPartition ("default/default"). This meant runtime endpoint
|
||||
// resolution and command-tree generation read different cache files, and
|
||||
// under gray-release the two partitions carried disjoint product lists —
|
||||
// the historical root cause of `dws conference meeting create` failing
|
||||
// while `dws todo task list` succeeded on the same host.
|
||||
//
|
||||
// Keeping both sides funneled through config.EditionPartition is the
|
||||
// central invariant the fix enforces. If this test ever regresses, the
|
||||
// two-partition split almost certainly came back.
|
||||
func TestEditionPartition_SingleSourceOfTruth(t *testing.T) {
|
||||
t.Cleanup(func() { edition.Override(&edition.Hooks{}) })
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
edition string
|
||||
want string
|
||||
}{
|
||||
{"open edition falls through to default/default", "", config.DefaultPartition},
|
||||
{"explicit open edition remains default", "open", config.DefaultPartition},
|
||||
{"wukong overlay uses wukong/default", "wukong", "wukong/default"},
|
||||
{"custom edition is namespaced", "internal-lab", "internal-lab/default"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
edition.Override(&edition.Hooks{Name: tc.edition})
|
||||
legacy := editionPartition()
|
||||
shared := config.EditionPartition(edition.Get().Name)
|
||||
|
||||
if legacy != shared {
|
||||
t.Fatalf("editionPartition()=%q, config.EditionPartition()=%q — partition split regressed for edition %q", legacy, shared, tc.edition)
|
||||
}
|
||||
if legacy != tc.want {
|
||||
t.Fatalf("editionPartition()=%q, want %q for edition %q", legacy, tc.want, tc.edition)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+209
-40
@@ -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,53 @@ 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
|
||||
}
|
||||
if rawURI, ok := data["uri"].(string); ok && strings.TrimSpace(rawURI) != "" {
|
||||
data["authorizationUrl"] = apperrors.PATAuthorizationURL(rawURI)
|
||||
}
|
||||
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 +295,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 +355,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 unless it is the known legacy DingTalk hash-route variant.
|
||||
// That variant is normalized by the PAT error contract helper while still
|
||||
// preserving the original data.uri in structured output.
|
||||
return openBrowserFunc(apperrors.PATAuthorizationURL(rawURI))
|
||||
}
|
||||
|
||||
func printPATPollDebugResponse(output io.Writer, statusCode int, body []byte) {
|
||||
if os.Getenv("DWS_DEBUG_PAT_POLL") == "" {
|
||||
return
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if trimmed == "" {
|
||||
trimmed = "<empty body>"
|
||||
}
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintf(output, " ℹ PAT 轮询接口返回原文 (HTTP %d):\n", statusCode)
|
||||
fmt.Fprintf(output, " %s\n", trimmed)
|
||||
}
|
||||
|
||||
// handlePatAuthCheck is called by runner.executeInvocation when a PAT
|
||||
// authorization error is detected. It injects the server-assigned clientId
|
||||
// as x-robot-uid header, prints authorization details, opens the browser,
|
||||
@@ -338,9 +414,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 +437,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 != "" {
|
||||
_ = openPATAuthorizationURI(patData.Data.URI)
|
||||
}
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, openBrowser)}
|
||||
}
|
||||
|
||||
bold := color.New(color.Bold).SprintFunc()
|
||||
@@ -381,15 +478,12 @@ func handlePatAuthCheck(
|
||||
fmt.Fprintf(output, " %s %s\n", dim("ℹ"), patData.Data.Desc)
|
||||
}
|
||||
if patData.Data.URI != "" {
|
||||
fmt.Fprintf(output, " %s %s\n\n", dim("🔗"), cyan(patData.Data.URI))
|
||||
// Best-effort browser open.
|
||||
_ = tryOpenBrowser(patData.Data.URI)
|
||||
}
|
||||
|
||||
// If no flowId, we can't poll — fall back to returning PATError for host-app.
|
||||
if patData.Data.FlowID == "" {
|
||||
fmt.Fprintln(output)
|
||||
return executor.Result{}, patErr
|
||||
authURL := apperrors.PATAuthorizationURL(patData.Data.URI)
|
||||
fmt.Fprintf(output, " %s 授权链接: %s\n", dim("🔗"), cyan(authURL))
|
||||
fmt.Fprintf(output, " PAT_AUTHORIZATION_URL=%s\n\n", authURL)
|
||||
if openBrowser {
|
||||
_ = openPATAuthorizationURI(authURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Poll the device flow status until user authorizes, rejects, or timeout.
|
||||
@@ -411,6 +505,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 +576,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,41 +698,49 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out
|
||||
var pollResp authpkg.DevicePollResponse
|
||||
if err := json.Unmarshal(bodyBytes, &pollResp); err != nil {
|
||||
slog.Debug("PAT poll: failed to parse response", "error", err, "body", string(bodyBytes))
|
||||
printPATPollDebugResponse(output, resp.StatusCode, bodyBytes)
|
||||
continue
|
||||
}
|
||||
|
||||
status := authpkg.ParseDeviceFlowStatus(pollResp.Data.Status, pollResp.Success)
|
||||
pollData := pollResp.EffectiveData()
|
||||
status := authpkg.ParseDeviceFlowStatus(pollData.Status, pollResp.Success)
|
||||
switch status {
|
||||
case authpkg.StatusApproved:
|
||||
fmt.Fprintln(output) // clear the polling line
|
||||
return status, pollResp.Data.AuthCode, nil
|
||||
return status, pollData.AuthCode, nil
|
||||
case authpkg.StatusRejected, authpkg.StatusExpired:
|
||||
fmt.Fprintln(output) // clear the polling line
|
||||
return status, "", nil
|
||||
case authpkg.StatusPending:
|
||||
// keep polling
|
||||
default:
|
||||
// ParseDeviceFlowStatus normalizes empty+!success to EXPIRED,
|
||||
// so this branch handles truly unknown statuses.
|
||||
fmt.Fprintln(output)
|
||||
printPATPollDebugResponse(output, resp.StatusCode, bodyBytes)
|
||||
return status, "", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tryOpenBrowser opens url in the default browser; errors are silently ignored.
|
||||
func tryOpenBrowser(url string) error {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
func browserOpenCommand(goos, rawURL string) *exec.Cmd {
|
||||
switch goos {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", url)
|
||||
return exec.Command("open", rawURL)
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", url)
|
||||
return exec.Command("xdg-open", rawURL)
|
||||
case "windows":
|
||||
cmd = exec.Command("cmd", "/c", "start", url)
|
||||
return exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// tryOpenBrowser opens rawURL in the default browser; errors are silently ignored.
|
||||
func tryOpenBrowser(rawURL string) error {
|
||||
cmd := browserOpenCommand(runtime.GOOS, rawURL)
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
@@ -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(authpkg.GetAppConfigPath(configDir)); 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(authpkg.GetAppConfigPath(tmpDir)); !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(authpkg.GetAppConfigPath(tmpDir)); !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,469 @@ 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(authpkg.GetAppConfigPath(tmpDir)); !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"},
|
||||
}
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Df72437f040f04a8295988ff71e690b35%26userCode%3D98JV-JSBL#/personalAuthorization?flowId=f72437f040f04a8295988ff71e690b35&userCode=98JV-JSBL"
|
||||
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-json","uri":"` + rawURI + `","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(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
if opened != rawURI {
|
||||
t.Fatalf("opened url = %q, want verbatim %q", opened, rawURI)
|
||||
}
|
||||
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, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI)
|
||||
}
|
||||
if got, _ := data["authorizationUrl"].(string); got != rawURI {
|
||||
t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI)
|
||||
}
|
||||
if got, ok := data["openBrowser"].(bool); !ok || !got {
|
||||
t.Fatalf("data.openBrowser = %#v, want true", data["openBrowser"])
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if got := buf.String(); !strings.Contains(got, "PAT_AUTHORIZATION_URL="+rawURI) {
|
||||
t.Fatalf("output missing copy-safe PAT_AUTHORIZATION_URL line:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_NormalizesLegacyHashRouteForBrowserAndOutput(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#%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN"
|
||||
wantURL := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN#/personalAuthorization?flowId=56b12fd3201d4efab9a9138672cf4deb&userCode=CFTC-27ZN"
|
||||
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-legacy-hash", "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 != wantURL {
|
||||
t.Fatalf("opened url = %q, want normalized %q", opened, wantURL)
|
||||
}
|
||||
if got := buf.String(); !strings.Contains(got, "PAT_AUTHORIZATION_URL="+wantURL) {
|
||||
t.Fatalf("output missing normalized PAT_AUTHORIZATION_URL line:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserOpenCommand_WindowsPreservesOpaquePATURI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Df72437f040f04a8295988ff71e690b35%26userCode%3D98JV-JSBL#/personalAuthorization?flowId=f72437f040f04a8295988ff71e690b35&userCode=98JV-JSBL"
|
||||
cmd := browserOpenCommand("windows", rawURI)
|
||||
if cmd == nil {
|
||||
t.Fatal("browserOpenCommand(windows) returned nil")
|
||||
}
|
||||
if got := cmd.Args[0]; got == "cmd" {
|
||||
t.Fatalf("windows browser opener must not route PAT URLs through cmd.exe: args=%v", cmd.Args)
|
||||
}
|
||||
if got := len(cmd.Args); got != 3 {
|
||||
t.Fatalf("windows browser opener args length = %d, want 3: %v", got, cmd.Args)
|
||||
}
|
||||
if got := cmd.Args[0]; got != "rundll32" {
|
||||
t.Fatalf("windows browser opener command = %q, want rundll32", got)
|
||||
}
|
||||
if got := cmd.Args[1]; got != "url.dll,FileProtocolHandler" {
|
||||
t.Fatalf("windows browser opener handler = %q, want url.dll,FileProtocolHandler", got)
|
||||
}
|
||||
if got := cmd.Args[2]; got != rawURI {
|
||||
t.Fatalf("windows browser opener URL arg = %q, want verbatim %q", got, 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIPv4HTTPClientHonoursHTTPProxyEnv guards the fix for #236 on the
|
||||
// IPv4-forcing client used by the legacy registry / discovery path. The
|
||||
// custom Transport overrides DialContext to force IPv4 — without an
|
||||
// explicit Proxy field it would also drop env-var proxy support.
|
||||
//
|
||||
// We can't reliably invoke tr.Proxy(req) here because http.ProxyFromEnvironment
|
||||
// memoises the env vars on first call (Go's envProxyOnce); ordering with other
|
||||
// tests that read proxy env early would make this flaky. Asserting that the
|
||||
// Transport's Proxy func points at http.ProxyFromEnvironment is sufficient to
|
||||
// catch the regression — the runtime takes care of reading HTTP_PROXY/HTTPS_PROXY
|
||||
// at process boot.
|
||||
func TestIPv4HTTPClientHonoursHTTPProxyEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := ipv4HTTPClient(5 * time.Second)
|
||||
tr, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("ipv4HTTPClient transport is %T, want *http.Transport", client.Transport)
|
||||
}
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("ipv4HTTPClient transport.Proxy is nil — HTTP_PROXY env will be ignored (regression of #236)")
|
||||
}
|
||||
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
|
||||
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
|
||||
if gotPC != wantPC {
|
||||
t.Errorf("ipv4HTTPClient transport.Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
|
||||
}
|
||||
}
|
||||
+89
-85
@@ -47,6 +47,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -120,8 +121,8 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
|
||||
// Common flag aliases and suggestions
|
||||
suggestions := map[string]string{
|
||||
"--json": "提示: 请使用 --format json 或 -f json 来输出 JSON 格式",
|
||||
"--method": "提示: dws auth login 默认使用 OAuth 设备流登录,无需指定 --method",
|
||||
"--device-flow": "提示: dws auth login 默认已使用设备流,无需 --device-flow 参数",
|
||||
"--method": "提示: dws auth login 默认使用 OAuth loopback 流;SSH/无头环境请加 --device 走设备流",
|
||||
"--device-flow": "提示: 设备流的标志名是 --device(不是 --device-flow),SSH/无头环境登录请用 dws auth login --device",
|
||||
"--email": "提示: dws 不支持邮箱/密码登录,请使用 dws auth login 进行扫码登录",
|
||||
"--code": "提示: dws 不支持验证码登录,请使用 dws auth login 进行扫码登录",
|
||||
"--corp-id": "提示: corp-id 会在登录时自动获取,无需手动指定",
|
||||
@@ -133,7 +134,28 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
|
||||
|
||||
for flag, suggestion := range suggestions {
|
||||
if strings.Contains(errMsg, "unknown flag: "+flag) {
|
||||
return fmt.Errorf("%w\n%s", err, suggestion)
|
||||
return apperrors.NewValidation(
|
||||
errMsg,
|
||||
apperrors.WithHint(suggestion),
|
||||
apperrors.WithReason("unknown_flag"),
|
||||
apperrors.WithCause(err),
|
||||
apperrors.WithActions(fmt.Sprintf("Run '%s --help' for valid flags", cmd.CommandPath())),
|
||||
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(errMsg, "unknown flag:") {
|
||||
fix := cmdutil.SuggestFlagFix(cmd, err)
|
||||
if fix.Suggestion != "" {
|
||||
return apperrors.NewValidation(
|
||||
errMsg,
|
||||
apperrors.WithHint(fix.Suggestion),
|
||||
apperrors.WithReason("unknown_flag"),
|
||||
apperrors.WithCause(err),
|
||||
apperrors.WithActions(fmt.Sprintf("Run '%s --help' for valid flags", cmd.CommandPath())),
|
||||
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +312,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
|
||||
utilityCommands := []*cobra.Command{
|
||||
newAuthCommand(),
|
||||
newAPICommand(flags),
|
||||
newSkillCommand(),
|
||||
newCacheCommand(),
|
||||
newConfigCommand(),
|
||||
@@ -420,20 +443,25 @@ func newCacheCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read cache refresh flags")
|
||||
}
|
||||
baseURL := DiscoveryBaseURL()
|
||||
|
||||
store := cacheStoreFromEnv()
|
||||
transportClient := transport.NewClient(nil)
|
||||
transportClient.AuthToken = resolveRuntimeAuthToken(cmd.Context(), "")
|
||||
// Market client here is only a fallback for Detail API calls inside
|
||||
// DiscoverAllRuntime; the primary server-list fetch below goes
|
||||
// through fetchRegistryServers so edition DiscoveryURL wins.
|
||||
service := discovery.NewService(
|
||||
market.NewClient(baseURL, nil),
|
||||
market.NewClient(DiscoveryBaseURL(), nil),
|
||||
transportClient,
|
||||
store,
|
||||
)
|
||||
servers, err := service.DiscoverServers(cmd.Context())
|
||||
|
||||
resp, err := fetchRegistryServers(cmd.Context(), ipv4HTTPClient(config.HTTPTimeout))
|
||||
if err != nil {
|
||||
return err
|
||||
return apperrors.NewDiscovery(fmt.Sprintf("cache refresh: fetch server list failed: %v", err))
|
||||
}
|
||||
servers := market.NormalizeServers(resp, "live_market")
|
||||
_ = store.SaveRegistry(service.CachePartition(), cache.RegistrySnapshot{Servers: servers})
|
||||
|
||||
selected := selectServersForProduct(servers, product)
|
||||
if strings.TrimSpace(product) != "" && len(selected) == 0 {
|
||||
@@ -650,18 +678,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,
|
||||
@@ -693,7 +713,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,
|
||||
@@ -1157,11 +1177,31 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
|
||||
sharedStore := cacheStoreFromEnv()
|
||||
coldTimeouts := resolvePluginColdTimeouts()
|
||||
|
||||
// Fan out HTTP and stdio discovery in parallel. Each goroutine resolves
|
||||
// its cache hit locally (no network) or runs a bounded cold-path probe.
|
||||
// Wall-clock cost ≈ max(individual plugin latencies), not the sum.
|
||||
// Phase A: stdio overlay-first registration (synchronous, no I/O).
|
||||
// Plugins whose overlay.json declares ToolOverrides register their full
|
||||
// command tree up-front from manifest metadata alone — no subprocess
|
||||
// handshake required. This fixes the "discovery fails → no commands
|
||||
// ever appear" lock-out and keeps `dws --help` reliable even when the
|
||||
// underlying MCP server is temporarily unavailable.
|
||||
var legacyStdioEntries []stdioEntry
|
||||
for _, e := range stdioEntries {
|
||||
cmds, _, ok := registerStdioServerFromOverlay(e.plugin, e.sc, runner, sharedStore)
|
||||
if !ok {
|
||||
legacyStdioEntries = append(legacyStdioEntries, e)
|
||||
continue
|
||||
}
|
||||
pluginCmds = append(pluginCmds, cmds...)
|
||||
}
|
||||
|
||||
// Phase B: fan out discovery in parallel.
|
||||
// - HTTP plugins: same behaviour as before (discovery-first).
|
||||
// - stdio overlay-first plugins: async cache refresh only; their
|
||||
// commands are already registered. Failures are non-fatal and do
|
||||
// NOT poison the warm-cache with a null-tools snapshot.
|
||||
// - stdio legacy plugins (overlay without toolOverrides): preserve
|
||||
// the old discovery-first path for backwards compatibility.
|
||||
httpResults := make([][]*cobra.Command, len(httpServers))
|
||||
stdioResults := make([][]*cobra.Command, len(stdioEntries))
|
||||
legacyStdioResults := make([][]*cobra.Command, len(legacyStdioEntries))
|
||||
var wg sync.WaitGroup
|
||||
for i, ps := range httpServers {
|
||||
wg.Add(1)
|
||||
@@ -1170,18 +1210,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...)
|
||||
}
|
||||
|
||||
@@ -1467,6 +1519,11 @@ func discoverStdioTools(p *plugin.Plugin, sc plugin.StdioServerClient, timeouts
|
||||
// buildStdioCommands constructs Cobra commands from a tool list and
|
||||
// registers the runtime dispatch state (StdioClient + dynamic server).
|
||||
// Returns nil for an empty tool list.
|
||||
//
|
||||
// This is the legacy discovery-first path, used only for stdio plugins whose
|
||||
// overlay.json does NOT carry toolOverrides. Plugins that ship toolOverrides
|
||||
// register commands up-front via registerStdioServerFromOverlay, bypassing
|
||||
// this function entirely (see plugin_stdio_overlay.go).
|
||||
func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []transport.ToolDescriptor, runner executor.Runner) []*cobra.Command {
|
||||
if len(tools) == 0 {
|
||||
slog.Debug("plugin: stdio server has no tools",
|
||||
@@ -1474,44 +1531,14 @@ func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []t
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build CLIOverlay: use manifest CLI metadata if present, else auto-generate.
|
||||
serverID := sc.Key
|
||||
overlay := market.CLIOverlay{
|
||||
ID: serverID,
|
||||
Command: serverID,
|
||||
}
|
||||
if srv, ok := p.Manifest.MCPServers[sc.Key]; ok && len(srv.CLI) > 0 {
|
||||
cliData := srv.CLI
|
||||
// If cli is a JSON string, treat it as a relative file path to an overlay file.
|
||||
if len(cliData) > 0 && cliData[0] == '"' {
|
||||
var cliPath string
|
||||
if err := json.Unmarshal(cliData, &cliPath); err == nil && cliPath != "" {
|
||||
absPath := filepath.Join(p.Root, cliPath)
|
||||
if fileData, readErr := os.ReadFile(absPath); readErr == nil {
|
||||
cliData = fileData
|
||||
} else {
|
||||
slog.Warn("plugin: failed to read CLI overlay file",
|
||||
"plugin", p.Manifest.Name, "path", absPath, "error", readErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(cliData, &overlay); err != nil {
|
||||
slog.Warn("plugin: failed to parse CLI overlay for stdio server",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
}
|
||||
if overlay.ID == "" {
|
||||
overlay.ID = serverID
|
||||
}
|
||||
if overlay.Command == "" {
|
||||
overlay.Command = serverID
|
||||
}
|
||||
}
|
||||
overlay := resolveStdioOverlay(p, sc)
|
||||
|
||||
// Auto-generate ToolOverrides from discovered tools when not provided.
|
||||
// Auto-generate ToolOverrides from discovered tools when not provided
|
||||
// by the manifest/overlay (legacy discovery-first path).
|
||||
if len(overlay.ToolOverrides) == 0 {
|
||||
overlay.ToolOverrides = make(map[string]market.CLIToolOverride)
|
||||
if len(overlay.Prefixes) == 0 {
|
||||
overlay.Prefixes = []string{serverID}
|
||||
overlay.Prefixes = []string{overlay.ID}
|
||||
}
|
||||
for _, tool := range tools {
|
||||
overlay.ToolOverrides[tool.Name] = market.CLIToolOverride{
|
||||
@@ -1520,43 +1547,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)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T) {
|
||||
@@ -106,6 +107,98 @@ func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheRefreshHonorsEditionDiscoveryURL asserts the `dws cache refresh`
|
||||
// command routes its server-list fetch through edition.Hooks.DiscoveryURL /
|
||||
// DiscoveryHeaders when they are set, instead of the default Market endpoint.
|
||||
// Kept deliberately generic (no edition-specific strings) — concrete values
|
||||
// belong to the overlay repo that installs the hooks, not to this open core.
|
||||
func TestCacheRefreshHonorsEditionDiscoveryURL(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
|
||||
var (
|
||||
editionHits atomic.Int32
|
||||
marketHits atomic.Int32
|
||||
gotHeaders atomic.Value // map[string]string
|
||||
)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/cli/edition/apis":
|
||||
editionHits.Add(1)
|
||||
snapshot := map[string]string{
|
||||
"x-test-edition": r.Header.Get("x-test-edition"),
|
||||
"x-test-client": r.Header.Get("x-test-client"),
|
||||
}
|
||||
gotHeaders.Store(snapshot)
|
||||
_ = json.NewEncoder(w).Encode(market.ListResponse{
|
||||
Metadata: market.ListMetadata{Count: 1},
|
||||
Servers: []market.ServerEnvelope{
|
||||
{
|
||||
Server: market.RegistryServer{
|
||||
Name: "Edition Service",
|
||||
Remotes: []market.RegistryRemote{{Type: "streamable-http", URL: "https://example.invalid/mcp"}},
|
||||
},
|
||||
Meta: market.EnvelopeMeta{
|
||||
Registry: market.RegistryMetadata{Status: "active"},
|
||||
CLI: market.CLIOverlay{ID: "edition-service", Command: "edition-service"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
case "/cli/discovery/apis":
|
||||
marketHits.Add(1)
|
||||
http.Error(w, "market endpoint must not be called when edition DiscoveryURL is set", http.StatusNotFound)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "testing",
|
||||
DiscoveryURL: srv.URL + "/cli/edition/apis",
|
||||
DiscoveryHeaders: func() map[string]string {
|
||||
return map[string]string{
|
||||
"x-test-edition": "custom",
|
||||
"x-test-client": "cli-refresh",
|
||||
}
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(&edition.Hooks{}) })
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
cmd := newCacheCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"refresh"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
if got := editionHits.Load(); got != 1 {
|
||||
t.Fatalf("edition DiscoveryURL hits = %d, want 1", got)
|
||||
}
|
||||
if got := marketHits.Load(); got != 0 {
|
||||
t.Fatalf("market endpoint hits = %d, want 0 (edition DiscoveryURL must take precedence)", got)
|
||||
}
|
||||
|
||||
headers, _ := gotHeaders.Load().(map[string]string)
|
||||
if headers == nil {
|
||||
t.Fatal("captured request headers = nil, want edition DiscoveryHeaders to be applied")
|
||||
}
|
||||
if headers["x-test-edition"] != "custom" {
|
||||
t.Fatalf("x-test-edition header = %q, want %q", headers["x-test-edition"], "custom")
|
||||
}
|
||||
if headers["x-test-client"] != "cli-refresh" {
|
||||
t.Fatalf("x-test-client header = %q, want %q", headers["x-test-client"], "cli-refresh")
|
||||
}
|
||||
}
|
||||
|
||||
func saveCLIIDDetail(t *testing.T, store *cache.Store, partition, cliID string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
+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() {
|
||||
|
||||
+108
-8
@@ -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)
|
||||
}
|
||||
@@ -168,10 +195,21 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
|
||||
product, ok := catalog.FindProduct(invocation.CanonicalProduct)
|
||||
if !ok || strings.TrimSpace(product.Endpoint) == "" {
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
return r.handleCatalogMiss(ctx, invocation, "product missing from discovery catalog and no supplement/env override")
|
||||
}
|
||||
if _, ok := product.FindTool(invocation.Tool); !ok {
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
// Catalog knows the product but not the tool — this happens when the
|
||||
// catalog entry came from SupplementServers (endpoint-only, no tool
|
||||
// list). Trust directRuntimeEndpoint to re-resolve a working endpoint
|
||||
// for the tool. If that also misses, fall through to handleCatalogMiss
|
||||
// so stderr still carries the explicit not-resolved signal.
|
||||
if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok {
|
||||
if r.globalFlags != nil && r.globalFlags.DryRun {
|
||||
invocation.DryRun = true
|
||||
}
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
}
|
||||
return r.handleCatalogMiss(ctx, invocation, fmt.Sprintf("tool %q not declared by product %q in discovery catalog", invocation.Tool, invocation.CanonicalProduct))
|
||||
}
|
||||
if r.globalFlags != nil && r.globalFlags.DryRun {
|
||||
invocation.DryRun = true
|
||||
@@ -181,9 +219,53 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
if override, ok := productEndpointOverride(invocation.CanonicalProduct); ok {
|
||||
endpoint = override
|
||||
}
|
||||
// Multi-server tool-name authority correction.
|
||||
//
|
||||
// When two envelope servers share the same cli.command (e.g. group-chat
|
||||
// and im both publish `dws chat ...`), the endpoints[cmd] map in
|
||||
// registerDynamicServer is the second-writer wins, and catalog FindProduct
|
||||
// may pick the wrong product's Endpoint for a tool whose real owner is
|
||||
// a different server. Cross-check the canonical tool→endpoint map: when
|
||||
// the per-tool endpoint exists and differs from the per-product endpoint
|
||||
// catalog returned, trust the tool-owner endpoint (the server that
|
||||
// actually declares this tool in its toolOverrides).
|
||||
if toolEndpoint, ok := directRuntimeToolEndpoint(invocation.Tool); ok && toolEndpoint != "" && toolEndpoint != endpoint {
|
||||
endpoint = toolEndpoint
|
||||
}
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
}
|
||||
|
||||
// handleCatalogMiss decides what to do when discovery catalog does not cover the
|
||||
// requested product / tool and no `directRuntimeEndpoint` match fired earlier.
|
||||
//
|
||||
// Previously every catalog miss silently fell through to EchoRunner, which
|
||||
// returns an empty `executor.Result{Response: nil}`. The helper-invocation
|
||||
// adapter then converted that into `&edition.ToolResult{}`, whose `Content`
|
||||
// marshals to `null`, surfacing as `{"Content": null}` at the CLI. Users had no
|
||||
// signal that endpoint resolution failed — see the fix-wukong-discovery-missing-servers plan (Phase 3) for the full trace.
|
||||
//
|
||||
// New contract:
|
||||
// - Dry-run (invocation.DryRun or globalFlags.DryRun): keep EchoRunner so
|
||||
// `--dry-run` still prints the planned payload without real execution.
|
||||
// - Otherwise: return an explicit apperrors.NewAPI("endpoint_not_resolved")
|
||||
// with the offending product/tool attached. This fails fast to stderr and
|
||||
// makes missing envelopes / supplement gaps immediately visible.
|
||||
func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation executor.Invocation, detail string) (executor.Result, error) {
|
||||
dryRun := invocation.DryRun || (r.globalFlags != nil && r.globalFlags.DryRun)
|
||||
if dryRun {
|
||||
invocation.DryRun = true
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
return executor.Result{}, apperrors.NewAPI(
|
||||
fmt.Sprintf("endpoint not resolved for product %q (tool %q): %s", invocation.CanonicalProduct, invocation.Tool, detail),
|
||||
apperrors.WithOperation("discovery.resolve"),
|
||||
apperrors.WithReason("endpoint_not_resolved"),
|
||||
apperrors.WithServerKey(invocation.CanonicalProduct),
|
||||
apperrors.WithHint("产品 envelope 可能未下发到 discovery,或已经被 serverDeps fail-fast 丢弃;可执行 'dws cache refresh' 强制重新 discovery,仍失败请向 Portal 确认 envelope 状态。"),
|
||||
apperrors.WithActions("dws cache refresh"),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (result executor.Result, retErr error) {
|
||||
// Route stdio:// endpoints to the local StdioClient — no HTTP, no auth.
|
||||
if IsStdioEndpoint(endpoint) {
|
||||
@@ -295,6 +377,17 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
if err := r.preflightDocDownload(callCtx, tc, endpoint, invocation); err != nil {
|
||||
if patCheck := apperrors.AsPatAuthCheckError(err); patCheck != nil {
|
||||
if IsPatRetrying(ctx) {
|
||||
return executor.Result{}, patCheck
|
||||
}
|
||||
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
captureRuntimeFailure(invocation, err, err)
|
||||
return executor.Result{}, err
|
||||
}
|
||||
|
||||
callStart := time.Now()
|
||||
callResult, err := tc.CallTool(callCtx, endpoint, invocation.Tool, invocation.Params)
|
||||
RecordTiming(ctx, "mcp_call", time.Since(callStart))
|
||||
@@ -579,11 +672,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 != "" {
|
||||
@@ -591,7 +690,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)
|
||||
}
|
||||
}
|
||||
+252
-26
@@ -17,6 +17,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -25,7 +26,12 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
mockmcp "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/test/mock_mcp"
|
||||
)
|
||||
@@ -33,6 +39,13 @@ import (
|
||||
func setupRuntimeCommandTest(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
// Isolate keychain storage so concurrent test packages can't leak a
|
||||
// real auth token into runtime tests via the shared on-disk keychain
|
||||
// location. We deliberately do NOT reset the process-wide token
|
||||
// cache here: getCachedRuntimeToken uses sync.Once and is read by
|
||||
// detached preload goroutines spawned per Run(); replacing the Once
|
||||
// races with those still-running goroutines.
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
|
||||
discoverySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(contactDiscoveryResponse())
|
||||
@@ -305,6 +318,196 @@ func TestRuntimeRunnerInjectsAuthTokenFromFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv(authpkg.AgentCodeEnv, " cursor ")
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers["x-dingtalk-dws-agent-code"]; got != "cursor" {
|
||||
t.Fatalf("x-dingtalk-dws-agent-code = %q, want cursor", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocDownloadPreflightRejectsAXLSBeforeDownloadPAT(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
|
||||
|
||||
var calls []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := jsonRPCToolName(req)
|
||||
calls = append(calls, name)
|
||||
switch name {
|
||||
case docGetDocumentInfoTool:
|
||||
writeJSONRPCToolResult(t, w, req, map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"contentType": "ALIDOC",
|
||||
"extension": "axls",
|
||||
"nodeType": "file",
|
||||
},
|
||||
}, false)
|
||||
case docDownloadFileTool:
|
||||
t.Fatalf("download_file should not be called for axls")
|
||||
default:
|
||||
http.Error(w, "unexpected tool "+name, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
runner := runtimeRunnerForHTTPTest(server)
|
||||
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
|
||||
CanonicalProduct: docProductID,
|
||||
Tool: docDownloadFileTool,
|
||||
CanonicalPath: "doc.download_file",
|
||||
Params: map[string]any{"nodeId": "axls-node"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("executeInvocation() error = nil, want axls rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "extension=axls") {
|
||||
t.Fatalf("executeInvocation() error = %v, want extension=axls guidance", err)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("executeInvocation() error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("error category = %q, want validation", typed.Category)
|
||||
}
|
||||
if typed.Reason != "unsupported_alidoc_extension" {
|
||||
t.Fatalf("error reason = %q, want unsupported_alidoc_extension", typed.Reason)
|
||||
}
|
||||
if got := strings.Join(calls, ","); got != docGetDocumentInfoTool {
|
||||
t.Fatalf("tool calls = %q, want only %s", got, docGetDocumentInfoTool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocDownloadPreflightAllowsNonAXLSDownload(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
|
||||
|
||||
var calls []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := jsonRPCToolName(req)
|
||||
calls = append(calls, name)
|
||||
switch name {
|
||||
case docGetDocumentInfoTool:
|
||||
writeJSONRPCToolResult(t, w, req, map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"contentType": "DRIVE",
|
||||
"extension": "xlsx",
|
||||
"nodeType": "file",
|
||||
},
|
||||
}, false)
|
||||
case docDownloadFileTool:
|
||||
writeJSONRPCToolResult(t, w, req, map[string]any{
|
||||
"resourceUrl": []any{"https://example.invalid/file.xlsx"},
|
||||
}, false)
|
||||
default:
|
||||
http.Error(w, "unexpected tool "+name, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
runner := runtimeRunnerForHTTPTest(server)
|
||||
result, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
|
||||
CanonicalProduct: docProductID,
|
||||
Tool: docDownloadFileTool,
|
||||
CanonicalPath: "doc.download_file",
|
||||
Params: map[string]any{"nodeId": "xlsx-node"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("executeInvocation() error = %v", err)
|
||||
}
|
||||
if got := strings.Join(calls, ","); got != docGetDocumentInfoTool+","+docDownloadFileTool {
|
||||
t.Fatalf("tool calls = %q, want preflight then download", got)
|
||||
}
|
||||
content, ok := result.Response["content"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("response.content = %#v, want map", result.Response["content"])
|
||||
}
|
||||
if _, ok := content["resourceUrl"]; !ok {
|
||||
t.Fatalf("response.content.resourceUrl missing: %#v", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocDownloadPreflightPATAuthorizationUsesExistingHandler(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
|
||||
|
||||
originalOpenBrowser := openBrowserFunc
|
||||
var openedURI string
|
||||
openBrowserFunc = func(uri string) error {
|
||||
openedURI = uri
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { openBrowserFunc = originalOpenBrowser })
|
||||
|
||||
const authURI = "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-1%26userCode%3DCODE#/personalAuthorization?flowId=flow-1&userCode=CODE"
|
||||
var calls []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := jsonRPCToolName(req)
|
||||
calls = append(calls, name)
|
||||
switch name {
|
||||
case docGetDocumentInfoTool:
|
||||
writeJSONRPCToolResult(t, w, req, map[string]any{
|
||||
"code": "PAT_MEDIUM_RISK_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"flowId": "flow-1",
|
||||
"uri": authURI,
|
||||
"clientId": "client-1",
|
||||
},
|
||||
}, false)
|
||||
case docDownloadFileTool:
|
||||
t.Fatalf("download_file should not be called before preflight PAT authorization")
|
||||
default:
|
||||
http.Error(w, "unexpected tool "+name, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
runner := runtimeRunnerForHTTPTest(server)
|
||||
runner.globalFlags.Format = "json"
|
||||
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
|
||||
CanonicalProduct: docProductID,
|
||||
Tool: docDownloadFileTool,
|
||||
CanonicalPath: "doc.download_file",
|
||||
Params: map[string]any{"nodeId": "pat-node"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("executeInvocation() error = nil, want PAT error")
|
||||
}
|
||||
var patErr *apperrors.PATError
|
||||
if !errors.As(err, &patErr) {
|
||||
t.Fatalf("executeInvocation() error = %T, want *errors.PATError", err)
|
||||
}
|
||||
if openedURI != authURI {
|
||||
t.Fatalf("opened URI = %q, want %q", openedURI, authURI)
|
||||
}
|
||||
if got := strings.Join(calls, ","); got != docGetDocumentInfoTool {
|
||||
t.Fatalf("tool calls = %q, want only %s before PAT authorization", got, docGetDocumentInfoTool)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeRunnerRejectsUnauthenticatedRequest verifies that requests without
|
||||
// a valid token are rejected with a clear error before making any network call.
|
||||
func TestRuntimeRunnerRejectsUnauthenticatedRequest(t *testing.T) {
|
||||
@@ -336,7 +539,17 @@ func TestRuntimeRunnerRejectsUnauthenticatedRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerFallsBackForUnavailableProduct(t *testing.T) {
|
||||
// TestRuntimeRunnerErrorsForUnavailableProduct pins down the post-fix
|
||||
// (fix-wukong-discovery-missing-servers Phase 3) behaviour: when the catalog
|
||||
// does not carry the requested product (here `contact` against a
|
||||
// doc-only fixture), `runtimeRunner.Run` must surface an explicit
|
||||
// `endpoint_not_resolved` error instead of silently echoing an empty result
|
||||
// back to the user — the latter is what historically produced `{"Content":
|
||||
// null}` output on `dws doc list`.
|
||||
//
|
||||
// Dry-run / compat semantics continue to route through EchoRunner and are
|
||||
// covered by other tests in this file (TestRuntime*Dry*, TestCompat*).
|
||||
func TestRuntimeRunnerErrorsForUnavailableProduct(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
server := mockmcp.DefaultServer()
|
||||
defer server.Close()
|
||||
@@ -349,33 +562,16 @@ func TestRuntimeRunnerFallsBackForUnavailableProduct(t *testing.T) {
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"-f", "json", "contact", "user", "get-self"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() error = nil, want endpoint_not_resolved error\noutput:\n%s", out.String())
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Invocation struct {
|
||||
Implemented bool `json:"implemented"`
|
||||
CanonicalProduct string `json:"canonical_product"`
|
||||
Tool string `json:"tool"`
|
||||
} `json:"invocation"`
|
||||
Response map[string]any `json:"response"`
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "endpoint not resolved") {
|
||||
t.Fatalf("error message = %q, want it to contain \"endpoint not resolved\"", msg)
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
|
||||
if payload.Invocation.Implemented {
|
||||
t.Fatalf("implemented = true, want false for fallback")
|
||||
}
|
||||
if payload.Invocation.CanonicalProduct != "contact" {
|
||||
t.Fatalf("canonical_product = %q, want contact", payload.Invocation.CanonicalProduct)
|
||||
}
|
||||
if payload.Invocation.Tool != "get_current_user_profile" {
|
||||
t.Fatalf("tool = %q, want get_current_user_profile", payload.Invocation.Tool)
|
||||
}
|
||||
if payload.Response != nil {
|
||||
t.Fatalf("response = %#v, want nil for echo fallback", payload.Response)
|
||||
if !strings.Contains(msg, "contact") {
|
||||
t.Fatalf("error message = %q, want it to mention product \"contact\"", msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -646,6 +842,36 @@ func contentScanServer() *mockmcp.Server {
|
||||
return mockmcp.MustNewServer(fixture)
|
||||
}
|
||||
|
||||
func runtimeRunnerForHTTPTest(server *httptest.Server) *runtimeRunner {
|
||||
client := transport.NewClient(server.Client())
|
||||
client.Stderr = &bytes.Buffer{}
|
||||
return &runtimeRunner{
|
||||
transport: client,
|
||||
globalFlags: &GlobalFlags{Token: "test-token", Timeout: 30},
|
||||
}
|
||||
}
|
||||
|
||||
func jsonRPCToolName(req map[string]any) string {
|
||||
params, _ := req["params"].(map[string]any)
|
||||
if params == nil {
|
||||
return ""
|
||||
}
|
||||
name, _ := params["name"].(string)
|
||||
return name
|
||||
}
|
||||
|
||||
func writeJSONRPCToolResult(t *testing.T, w http.ResponseWriter, req map[string]any, content map[string]any, isError bool) {
|
||||
t.Helper()
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req["id"],
|
||||
"result": map[string]any{
|
||||
"content": content,
|
||||
"isError": isError,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestClassifyToolResultHookPreemptsBusinessError(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
// TestMain isolates the on-disk keychain storage to a process-wide
|
||||
// temporary directory for the entire internal/app test binary.
|
||||
//
|
||||
// Background: getCachedRuntimeToken caches the auth token via sync.Once
|
||||
// for the process lifetime. Whichever test triggers it first locks in the
|
||||
// cached value. Several tests in this package (e.g. TestSkillInstallInvalidTarget)
|
||||
// call SaveTokenData and then exec a CLI command that triggers Once.Do; if
|
||||
// keychain storage points at the developer's real ~/Library/Application
|
||||
// Support/dws-cli (or ~/.local/share/dws-cli on Linux), a real token can be
|
||||
// written there and cached process-wide, breaking later tests that assume
|
||||
// "no auth" — most notably TestRuntimeRunnerRejectsUnauthenticatedRequest.
|
||||
//
|
||||
// Setting keychain.StorageDirEnv here forces every keychain read/write in
|
||||
// this binary into a per-process tempdir, eliminating that contamination
|
||||
// without touching production code.
|
||||
func TestMain(m *testing.M) {
|
||||
tmpDir, err := os.MkdirTemp("", "dws-app-test-keychain-")
|
||||
if err != nil {
|
||||
panic("create test keychain tempdir: " + err.Error())
|
||||
}
|
||||
if err := os.Setenv(keychain.StorageDirEnv, tmpDir); err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -56,6 +57,19 @@ func (a *toolCallerAdapter) DryRun() bool {
|
||||
func convertResult(r executor.Result) *edition.ToolResult {
|
||||
resp := r.Response
|
||||
if resp == nil {
|
||||
// After the fix-wukong-discovery-missing-servers Phase 3 change,
|
||||
// runtimeRunner.Run returns an explicit error for catalog misses
|
||||
// instead of an empty Response, so this branch should only be
|
||||
// reachable for unit tests / unexpected runners. Log a warning so
|
||||
// any future regression (silent `{"Content": null}` on the CLI)
|
||||
// leaves a trace in the file logger / stderr.
|
||||
slog.Warn(
|
||||
"tool_caller_adapter: empty runner response — upstream should surface an error instead",
|
||||
"product", r.Invocation.CanonicalProduct,
|
||||
"tool", r.Invocation.Tool,
|
||||
"kind", r.Invocation.Kind,
|
||||
"dry_run", r.Invocation.DryRun,
|
||||
)
|
||||
return &edition.ToolResult{}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -57,6 +58,14 @@ func newUpgradeCommand() *cobra.Command {
|
||||
dws upgrade -y # 跳过确认直接升级`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if h := edition.Get(); h != nil && h.IsEmbedded {
|
||||
name := h.Name
|
||||
if name == "" {
|
||||
name = "embedded"
|
||||
}
|
||||
return fmt.Errorf("当前运行在嵌入模式(%s),dws upgrade 已禁用;请通过宿主完成升级", name)
|
||||
}
|
||||
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
format := resolveUpgradeFormat(cmd)
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestUpgradeCommand_BlockedInEmbeddedMode(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{IsEmbedded: true, Name: "embedded"})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"check", []string{"--check"}},
|
||||
{"list", []string{"--list"}},
|
||||
{"rollback", []string{"--rollback"}},
|
||||
{"plain", []string{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := newUpgradeCommand()
|
||||
var out, errBuf bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errBuf)
|
||||
cmd.SetArgs(tc.args)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("upgrade %v in embedded mode must return error, got nil", tc.args)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "嵌入模式") {
|
||||
t.Errorf("error message should mention 嵌入模式, got: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "embedded") {
|
||||
t.Errorf("error message should include edition name, got: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "dws upgrade") {
|
||||
t.Errorf("error message should reference dws upgrade for clarity, got: %q", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeCommand_NotBlockedInOpenSourceMode(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{IsEmbedded: false, Name: "open"})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
cmd := newUpgradeCommand()
|
||||
var out, errBuf bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errBuf)
|
||||
cmd.SetArgs([]string{"--check"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil && strings.Contains(err.Error(), "嵌入模式") {
|
||||
t.Errorf("open-source mode must not be blocked by embedded guard, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -16,21 +16,31 @@ package auth
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
configpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
const (
|
||||
// appConfigFile is the filename for storing app credentials.
|
||||
appConfigFile = "app.json"
|
||||
// appConfigFile is the filename for the open-source edition's app
|
||||
// credentials store. Sibling editions get a name-suffixed file via
|
||||
// config.EditionFileName so two dws binaries sharing the same config
|
||||
// directory (~/.dws or DWS_CONFIG_DIR) cannot read/write each other's
|
||||
// credentials. See GetAppConfigPath for the path derivation contract.
|
||||
appConfigBase = "app"
|
||||
appConfigExt = ".json"
|
||||
appConfigFile = appConfigBase + appConfigExt
|
||||
)
|
||||
|
||||
// AppConfig represents the application credentials configuration.
|
||||
// This is stored in ~/.dws/app.json with the client secret securely stored in keychain.
|
||||
// This is stored in the edition-specific app config file, with the client
|
||||
// secret securely stored in keychain when present.
|
||||
type AppConfig struct {
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret SecretInput `json:"clientSecret"`
|
||||
@@ -53,9 +63,14 @@ var (
|
||||
cachedResolvedMu sync.RWMutex
|
||||
)
|
||||
|
||||
// GetAppConfigPath returns the path to the app config file.
|
||||
// GetAppConfigPath returns the path to the app config file for the
|
||||
// currently-active edition. The filename is partitioned by edition so that
|
||||
// two dws binaries from different editions sharing the same configDir
|
||||
// (typically ~/.dws or DWS_CONFIG_DIR) cannot read or overwrite each
|
||||
// other's credentials. Open-source stays on "app.json" for backwards
|
||||
// compatibility; sibling editions land on "app-<edition>.json".
|
||||
func GetAppConfigPath(configDir string) string {
|
||||
return filepath.Join(configDir, appConfigFile)
|
||||
return filepath.Join(configDir, configpkg.EditionFileName(edition.Get().Name, appConfigBase, appConfigExt))
|
||||
}
|
||||
|
||||
// LoadAppConfig loads the app configuration from disk.
|
||||
@@ -105,6 +120,7 @@ func SaveAppConfig(configDir string, config *AppConfig) error {
|
||||
if err := helpers.AtomicWriteJSON(path, append(data, '\n')); err != nil {
|
||||
return fmt.Errorf("writing app config: %w", err)
|
||||
}
|
||||
cleanupLegacySiblingAppConfig(configDir, config)
|
||||
|
||||
// Update cache
|
||||
cachedAppConfigMu.Lock()
|
||||
@@ -121,6 +137,34 @@ func SaveAppConfig(configDir string, config *AppConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupLegacySiblingAppConfig(configDir string, config *AppConfig) {
|
||||
if config == nil || config.ClientID == "" || configpkg.IsOpenEdition(edition.Get().Name) {
|
||||
return
|
||||
}
|
||||
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
if legacyPath == GetAppConfigPath(configDir) {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var legacy AppConfig
|
||||
if err := json.Unmarshal(data, &legacy); err != nil {
|
||||
return
|
||||
}
|
||||
if legacy.ClientID != config.ClientID {
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) {
|
||||
slog.Debug("auth: best-effort cleanup of legacy app config failed", "path", legacyPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAppConfig removes the app configuration and associated keychain secrets.
|
||||
func DeleteAppConfig(configDir string) error {
|
||||
// Load existing config to clean up keychain
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// Verifies that two dws binaries from different editions sharing the same
|
||||
// configDir (e.g. ~/.dws via DWS_CONFIG_DIR) read and write disjoint
|
||||
// app.json files. Without partitioning, a sibling edition's post-login
|
||||
// persistence path could leak its pinned ClientID into the open-source
|
||||
// build by reading the shared file.
|
||||
|
||||
func TestGetAppConfigPath_OpenEditionUsesLegacyName(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
for _, name := range []string{"", "open"} {
|
||||
edition.Override(&edition.Hooks{Name: name})
|
||||
got := GetAppConfigPath("/tmp/cfg")
|
||||
want := filepath.Join("/tmp/cfg", "app.json")
|
||||
if got != want {
|
||||
t.Fatalf("edition=%q: GetAppConfigPath = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAppConfigPath_SiblingEditionUsesSuffixedName(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
cases := []struct {
|
||||
editionName string
|
||||
wantFile string
|
||||
}{
|
||||
{"wukong", "app-wukong.json"},
|
||||
{"dev", "app-dev.json"},
|
||||
{"embedded", "app-embedded.json"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
edition.Override(&edition.Hooks{Name: tc.editionName})
|
||||
got := GetAppConfigPath("/tmp/cfg")
|
||||
want := filepath.Join("/tmp/cfg", tc.wantFile)
|
||||
if got != want {
|
||||
t.Fatalf("edition=%q: GetAppConfigPath = %q, want %q", tc.editionName, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAppConfigPath_OpenAndSiblingAreDisjoint(t *testing.T) {
|
||||
// End-to-end invariant: when the same configDir is observed from two
|
||||
// different editions, the resulting app.json paths must NOT collide.
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
const cfg = "/tmp/shared-cfg"
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "open"})
|
||||
openPath := GetAppConfigPath(cfg)
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
wukongPath := GetAppConfigPath(cfg)
|
||||
|
||||
if openPath == wukongPath {
|
||||
t.Fatalf("open and wukong editions share path %q; cross-edition leakage possible", openPath)
|
||||
}
|
||||
if filepath.Dir(openPath) != filepath.Dir(wukongPath) {
|
||||
t.Fatalf("paths landed in different directories (%q vs %q); partitioning should only differ by filename", filepath.Dir(openPath), filepath.Dir(wukongPath))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppConfigIO_OpenEditionDoesNotReadSiblingCredentials(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
wukongPath := GetAppConfigPath(configDir)
|
||||
if err := os.WriteFile(wukongPath, []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}`+"\n"), 0600); err != nil {
|
||||
t.Fatalf("writing sibling app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "open"})
|
||||
got, err := LoadAppConfig(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAppConfig(open) error = %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("open edition read sibling app config: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionRemovesMatchingLegacyAppConfig(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("matching legacy app config should be removed, stat error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, "app-wukong.json")); err != nil {
|
||||
t.Fatalf("sibling app config not written: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionKeepsDifferentLegacyAppConfig(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"open-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("different legacy app config should be preserved: %v", err)
|
||||
}
|
||||
if string(got) != string(legacyJSON) {
|
||||
t.Fatalf("legacy app config changed: got %q, want %q", got, legacyJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionKeepsMalformedLegacyAppConfig(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"wukong-cid"`)
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing malformed legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("malformed legacy app config should be preserved: %v", err)
|
||||
}
|
||||
if string(got) != string(legacyJSON) {
|
||||
t.Fatalf("malformed legacy app config changed: got %q, want %q", got, legacyJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_OpenEditionDoesNotCleanSiblingAppConfigs(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
siblingFiles := map[string][]byte{
|
||||
"app-wukong.json": []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n"),
|
||||
"app-dev.json": []byte(`{"clientId":"dev-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n"),
|
||||
}
|
||||
for name, data := range siblingFiles {
|
||||
if err := os.WriteFile(filepath.Join(configDir, name), data, 0600); err != nil {
|
||||
t.Fatalf("writing sibling app config %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "open"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "open-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(open) error = %v", err)
|
||||
}
|
||||
|
||||
for name, want := range siblingFiles {
|
||||
got, err := os.ReadFile(filepath.Join(configDir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("open edition should preserve sibling app config %s: %v", name, err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Fatalf("sibling app config %s changed: got %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionKeepsLegacyAppConfigWhenClientIDEmpty(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong empty client ID) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy app config should be preserved when client ID is empty: %v", err)
|
||||
}
|
||||
if string(got) != string(legacyJSON) {
|
||||
t.Fatalf("legacy app config changed: got %q, want %q", got, legacyJSON)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
const (
|
||||
// appTokenPrefix is the keychain account prefix for app-level tokens.
|
||||
appTokenPrefix = "app-token:"
|
||||
|
||||
// tokenExpiryBuffer is the buffer time before actual expiry to consider
|
||||
// the token as expired (same as user token: 5 minutes).
|
||||
tokenExpiryBuffer = 5 * time.Minute
|
||||
)
|
||||
|
||||
// AppTokenData stores the app-level access token obtained from the unified
|
||||
// POST /v1.0/oauth2/accessToken endpoint. It works for both new-style
|
||||
// (api.dingtalk.com) and legacy (oapi.dingtalk.com) APIs — the auth method
|
||||
// (header vs query param) is chosen by the caller based on the target host.
|
||||
type AppTokenData struct {
|
||||
AccessToken string `json:"access_token,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
|
||||
// Associated app credentials
|
||||
ClientID string `json:"client_id"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// IsTokenValid returns true if the access token has not expired.
|
||||
func (d *AppTokenData) IsTokenValid() bool {
|
||||
if d == nil || d.AccessToken == "" {
|
||||
return false
|
||||
}
|
||||
return time.Now().Before(d.ExpiresAt.Add(-tokenExpiryBuffer))
|
||||
}
|
||||
|
||||
// SaveAppTokenData persists AppTokenData to keychain, keyed by clientID.
|
||||
func SaveAppTokenData(data *AppTokenData) error {
|
||||
if data.ClientID == "" {
|
||||
return fmt.Errorf("clientID is required for saving app token data")
|
||||
}
|
||||
data.UpdatedAt = time.Now()
|
||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal app token data: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
for i := range jsonData {
|
||||
jsonData[i] = 0
|
||||
}
|
||||
}()
|
||||
|
||||
account := appTokenPrefix + data.ClientID
|
||||
if err := keychain.Set(keychain.Service, account, string(jsonData)); err != nil {
|
||||
return fmt.Errorf("save app token to keychain: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAppTokenData loads AppTokenData from keychain for the given clientID.
|
||||
// Returns nil, nil if no data exists.
|
||||
func LoadAppTokenData(clientID string) (*AppTokenData, error) {
|
||||
if clientID == "" {
|
||||
return nil, fmt.Errorf("clientID is required for loading app token data")
|
||||
}
|
||||
account := appTokenPrefix + clientID
|
||||
jsonStr, err := keychain.Get(keychain.Service, account)
|
||||
if err != nil {
|
||||
return nil, nil // Not found is not an error
|
||||
}
|
||||
if jsonStr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var data AppTokenData
|
||||
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
|
||||
return nil, fmt.Errorf("parse app token data: %w", err)
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// DeleteAppTokenData removes AppTokenData from keychain for the given clientID.
|
||||
func DeleteAppTokenData(clientID string) error {
|
||||
if clientID == "" {
|
||||
return nil
|
||||
}
|
||||
account := appTokenPrefix + clientID
|
||||
return keychain.Remove(keychain.Service, account)
|
||||
}
|
||||
|
||||
// --- Token Fetch Function ---
|
||||
|
||||
// FetchAppToken obtains an app-level access token from the unified endpoint:
|
||||
//
|
||||
// POST https://api.dingtalk.com/v1.0/oauth2/accessToken
|
||||
// Body: {"appKey":"X","appSecret":"X"}
|
||||
// Response: {"accessToken":"xxx","expireIn":7200}
|
||||
//
|
||||
// The same token works for both api.dingtalk.com and oapi.dingtalk.com.
|
||||
func FetchAppToken(ctx context.Context, appKey, appSecret string) (token string, expiresIn int64, err error) {
|
||||
body := map[string]string{
|
||||
"appKey": appKey,
|
||||
"appSecret": appSecret,
|
||||
}
|
||||
bodyBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("marshal request body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, AppAccessTokenURL, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := appTokenHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("fetching app token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("reading response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", 0, fmt.Errorf("获取 app token 失败 (HTTP %d): %s", resp.StatusCode, truncateStr(string(respBody), 200))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpireIn int64 `json:"expireIn"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return "", 0, fmt.Errorf("parsing app token response: %w", err)
|
||||
}
|
||||
if result.AccessToken == "" {
|
||||
return "", 0, fmt.Errorf("app token 响应缺少 accessToken 字段")
|
||||
}
|
||||
if result.ExpireIn <= 0 {
|
||||
result.ExpireIn = config.DefaultAccessTokenExpiry
|
||||
}
|
||||
return result.AccessToken, result.ExpireIn, nil
|
||||
}
|
||||
|
||||
// --- AppTokenProvider ---
|
||||
|
||||
// AppTokenProvider manages app-level token acquisition, caching and auto-refresh.
|
||||
type AppTokenProvider struct {
|
||||
ConfigDir string
|
||||
AppKey string
|
||||
AppSecret string
|
||||
HTTPClient *http.Client // injectable for testing; nil uses default
|
||||
}
|
||||
|
||||
// GetToken returns a valid app-level access token.
|
||||
// Tokens are cached in keychain and auto-refreshed when expired (with 5-min buffer).
|
||||
func (p *AppTokenProvider) GetToken(ctx context.Context) (string, error) {
|
||||
if p.AppKey == "" || p.AppSecret == "" {
|
||||
return "", fmt.Errorf("缺少应用凭证 (appKey/appSecret),请通过 --client-id/--client-secret 指定或先执行 dws auth login")
|
||||
}
|
||||
|
||||
// Load cached token data.
|
||||
data, err := LoadAppTokenData(p.AppKey)
|
||||
if err != nil {
|
||||
data = nil // Treat load errors as cache miss
|
||||
}
|
||||
|
||||
// Fast path: cached token is still valid.
|
||||
if data != nil && data.IsTokenValid() {
|
||||
return data.AccessToken, nil
|
||||
}
|
||||
|
||||
// Slow path: fetch a new token.
|
||||
if data == nil {
|
||||
data = &AppTokenData{ClientID: p.AppKey}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
token, expiresIn, fetchErr := FetchAppToken(ctx, p.AppKey, p.AppSecret)
|
||||
if fetchErr != nil {
|
||||
return "", fetchErr
|
||||
}
|
||||
data.AccessToken = token
|
||||
data.ExpiresAt = now.Add(time.Duration(expiresIn) * time.Second)
|
||||
|
||||
// Persist updated token data.
|
||||
if saveErr := SaveAppTokenData(data); saveErr != nil {
|
||||
// Log but don't fail — token is still usable this time.
|
||||
// Write to stderr so we don't corrupt stdout JSON output when piped
|
||||
// into jq/grep/etc.
|
||||
fmt.Fprintf(os.Stderr, "Warning: 无法缓存 app token: %v\n", saveErr)
|
||||
}
|
||||
|
||||
return data.AccessToken, nil
|
||||
}
|
||||
|
||||
// truncateStr truncates a string to maxLen characters.
|
||||
func truncateStr(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
// appTokenHTTPClient is the default HTTP client for app token operations.
|
||||
var appTokenHTTPClient = &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mustJSONBody returns a *bytes.Buffer containing the JSON encoding of v, or fails the test.
|
||||
func mustJSONBody(t *testing.T, v any) *bytes.Buffer {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(v); err != nil {
|
||||
t.Fatalf("json encode: %v", err)
|
||||
}
|
||||
return &buf
|
||||
}
|
||||
|
||||
func TestAppTokenData_IsTokenValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data *AppTokenData
|
||||
want bool
|
||||
}{
|
||||
{"nil data", nil, false},
|
||||
{"empty token", &AppTokenData{}, false},
|
||||
{"expired", &AppTokenData{
|
||||
AccessToken: "tok",
|
||||
ExpiresAt: time.Now().Add(-1 * time.Minute),
|
||||
}, false},
|
||||
{"within buffer", &AppTokenData{
|
||||
AccessToken: "tok",
|
||||
ExpiresAt: time.Now().Add(3 * time.Minute), // 3 min < 5 min buffer
|
||||
}, false},
|
||||
{"valid", &AppTokenData{
|
||||
AccessToken: "tok",
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.data.IsTokenValid(); got != tt.want {
|
||||
t.Errorf("IsTokenValid() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppTokenData_JSONRoundTrip(t *testing.T) {
|
||||
original := &AppTokenData{
|
||||
AccessToken: "app-tok-abc",
|
||||
ExpiresAt: time.Now().Add(2 * time.Hour).Truncate(time.Second),
|
||||
ClientID: "my-app-key",
|
||||
UpdatedAt: time.Now().Truncate(time.Second),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
var decoded AppTokenData
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if decoded.AccessToken != original.AccessToken {
|
||||
t.Errorf("AccessToken = %q, want %q", decoded.AccessToken, original.AccessToken)
|
||||
}
|
||||
if decoded.ClientID != original.ClientID {
|
||||
t.Errorf("ClientID = %q, want %q", decoded.ClientID, original.ClientID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAppToken_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
|
||||
}
|
||||
var body map[string]string
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["appKey"] != "mykey" || body["appSecret"] != "mysecret" {
|
||||
t.Errorf("got body %v, want appKey=mykey, appSecret=mysecret", body)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"accessToken": "app-tok-123",
|
||||
"expireIn": 7200,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
body := mustJSONBody(t, map[string]string{
|
||||
"appKey": "mykey",
|
||||
"appSecret": "mysecret",
|
||||
})
|
||||
resp, err := srv.Client().Post(srv.URL, "application/json", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpireIn int64 `json:"expireIn"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
if result.AccessToken != "app-tok-123" {
|
||||
t.Errorf("got token %q, want app-tok-123", result.AccessToken)
|
||||
}
|
||||
if result.ExpireIn != 7200 {
|
||||
t.Errorf("got expireIn %d, want 7200", result.ExpireIn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAppToken_EmptyToken(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"accessToken": "",
|
||||
"expireIn": 7200,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
body := mustJSONBody(t, map[string]string{
|
||||
"appKey": "badkey",
|
||||
"appSecret": "badsecret",
|
||||
})
|
||||
resp, err := srv.Client().Post(srv.URL, "application/json", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpireIn int64 `json:"expireIn"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
if result.AccessToken != "" {
|
||||
t.Errorf("expected empty accessToken, got %q", result.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppTokenProvider_GetToken_MissingCredentials(t *testing.T) {
|
||||
provider := &AppTokenProvider{
|
||||
ConfigDir: t.TempDir(),
|
||||
AppKey: "",
|
||||
AppSecret: "",
|
||||
}
|
||||
_, err := provider.GetToken(context.Background())
|
||||
if err == nil {
|
||||
t.Error("expected error for missing credentials")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateStr(t *testing.T) {
|
||||
if got := truncateStr("hello", 10); got != "hello" {
|
||||
t.Errorf("got %q, want hello", got)
|
||||
}
|
||||
if got := truncateStr("hello world", 5); got != "hello..." {
|
||||
t.Errorf("got %q, want hello...", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// AgentCodeEnv is the sole per-spawn environment variable the host injects
|
||||
// to declare "this process is driven by a third-party Agent host, render
|
||||
// authorization UI yourselves".
|
||||
AgentCodeEnv = "DINGTALK_DWS_AGENTCODE"
|
||||
)
|
||||
|
||||
// HostOwnsPATFlow reports whether the current process is running under a
|
||||
// third-party Agent host that will render the PAT authorization card
|
||||
// itself. The sole trigger is AgentCodeEnv (DINGTALK_DWS_AGENTCODE) being
|
||||
// non-empty. The CLI deliberately does not consult any other signal
|
||||
// (DINGTALK_AGENT / DWS_CHANNEL / the wire claw-type header) for this
|
||||
// decision so that server-side routing tags and the host-owned UI contract
|
||||
// remain independent concerns.
|
||||
func HostOwnsPATFlow() bool {
|
||||
return strings.TrimSpace(os.Getenv(AgentCodeEnv)) != ""
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// resetAppConfigCache clears cached app config so tests get a fresh load.
|
||||
func resetAppConfigCache() {
|
||||
cachedAppConfigMu.Lock()
|
||||
cachedAppConfig = nil
|
||||
cachedAppConfigMu.Unlock()
|
||||
cachedAppConfigOnce = sync.Once{}
|
||||
|
||||
cachedResolvedMu.Lock()
|
||||
cachedResolvedValid = false
|
||||
cachedResolvedID = ""
|
||||
cachedResolvedSecret = ""
|
||||
cachedResolvedMu.Unlock()
|
||||
}
|
||||
|
||||
// ─── Issue #155: Defensive credential reset ────────────────────────────
|
||||
//
|
||||
// These tests verify that both DeviceFlowProvider and OAuthProvider always
|
||||
// reset credential state and re-fetch clientID from MCP, regardless of what
|
||||
// previous login methods left in app.json or runtime state.
|
||||
|
||||
func TestIssue155V2_OAuthLoginNoSource_ThenDeviceLogin_ResetsAndFetches(t *testing.T) {
|
||||
// Scenario: OAuth login saved app.json WITHOUT Source field (the original bug).
|
||||
// Device flow should ignore the stale clientID, reset state, and re-fetch from MCP.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
// Simulate OAuth login: saved app.json with clientId but NO Source field
|
||||
oauthAppJSON := `{"clientId":"ding-oauth-stale","clientSecret":"","createdAt":"2026-04-24T00:00:00+08:00"}`
|
||||
if err := os.WriteFile(filepath.Join(dir, appConfigFile), []byte(oauthAppJSON), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile error: %v", err)
|
||||
}
|
||||
|
||||
// Verify precondition: ClientID() loads stale value from app.json
|
||||
resetAppConfigCache()
|
||||
gotID := ClientID()
|
||||
if gotID != "ding-oauth-stale" {
|
||||
t.Fatalf("precondition: ClientID() = %q, want 'ding-oauth-stale'", gotID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("precondition: IsClientIDFromMCP() should be false for app.json without Source")
|
||||
}
|
||||
|
||||
// Now create a DeviceFlowProvider — it should pick up the stale clientID
|
||||
provider := NewDeviceFlowProvider(dir, nil)
|
||||
if provider.clientID != "ding-oauth-stale" {
|
||||
t.Fatalf("provider.clientID = %q, want 'ding-oauth-stale' (from app.json)", provider.clientID)
|
||||
}
|
||||
|
||||
// Key assertion: after prepareCredentials(), the provider should have
|
||||
// cleared the stale clientID and be ready for MCP fetch.
|
||||
// We can't call Login() directly (needs real MCP server), but we can
|
||||
// verify the reset logic by calling the new method directly.
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue155V2_LegacyAppJson_ThenDeviceLogin_ResetsAndFetches(t *testing.T) {
|
||||
// Scenario: Legacy app.json (no Source field at all) exists from an old CLI version.
|
||||
// Device flow should reset and re-fetch.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
// Write legacy app.json
|
||||
legacyJSON := `{"clientId":"ding-legacy-old","clientSecret":"","createdAt":"2026-01-01T00:00:00+08:00"}`
|
||||
if err := os.WriteFile(filepath.Join(dir, appConfigFile), []byte(legacyJSON), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile error: %v", err)
|
||||
}
|
||||
|
||||
resetAppConfigCache()
|
||||
provider := NewDeviceFlowProvider(dir, nil)
|
||||
|
||||
// Verify stale clientID was loaded
|
||||
if provider.clientID != "ding-legacy-old" {
|
||||
t.Fatalf("provider.clientID = %q, want 'ding-legacy-old'", provider.clientID)
|
||||
}
|
||||
|
||||
// Reset should clear it
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue155V2_DirectAppJson_ThenDeviceLogin_ResetsAndFetches(t *testing.T) {
|
||||
// Scenario: User previously logged in with --client-id + --client-secret (direct mode).
|
||||
// app.json has a different clientId. Device flow should reset and re-fetch from MCP.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
// Simulate direct-mode app.json with clientSecret stored
|
||||
if err := SaveAppConfig(dir, &AppConfig{
|
||||
ClientID: "ding-direct-custom",
|
||||
ClientSecret: PlainSecret("some-secret"),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveAppConfig error: %v", err)
|
||||
}
|
||||
|
||||
resetAppConfigCache()
|
||||
provider := NewDeviceFlowProvider(dir, nil)
|
||||
|
||||
// Verify the direct clientID was loaded
|
||||
if provider.clientID != "ding-direct-custom" {
|
||||
t.Fatalf("provider.clientID = %q, want 'ding-direct-custom'", provider.clientID)
|
||||
}
|
||||
|
||||
// Reset should clear it
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue155V2_MCPFlagAlreadySet_ThenDeviceLogin_StillResets(t *testing.T) {
|
||||
// Scenario: MCP flag is already set from a previous device login in same process.
|
||||
// Device flow should still reset and re-fetch to ensure freshness.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
// Simulate: MCP flag is already set from previous login
|
||||
SetClientIDFromMCP("ding-old-mcp")
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
provider := NewDeviceFlowProvider(dir, nil)
|
||||
if provider.clientID != "ding-old-mcp" {
|
||||
t.Fatalf("provider.clientID = %q, want 'ding-old-mcp'", provider.clientID)
|
||||
}
|
||||
|
||||
// Reset should clear both clientID and MCP flag
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false after reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue155V2_NoAppJson_DeviceLogin_StillWorks(t *testing.T) {
|
||||
// Scenario: No app.json exists (first time login). Device flow should work normally.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
provider := NewDeviceFlowProvider(dir, nil)
|
||||
|
||||
// clientID should already be empty
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("provider.clientID = %q, want empty (no app.json)", provider.clientID)
|
||||
}
|
||||
|
||||
// Reset should be a no-op but not crash
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── OAuthProvider defensive reset (--force login) ─────────────────────
|
||||
|
||||
func TestIssue155V2_OAuthForceLogin_ResetsStaleCredentials(t *testing.T) {
|
||||
// Scenario: Previous login saved app.json with MCP-fetched clientID but
|
||||
// no Source marker. OAuth --force login should reset and re-fetch.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
// Simulate previous login: app.json with clientId but no Source
|
||||
staleJSON := `{"clientId":"ding-stale-oauth","clientSecret":"","createdAt":"2026-04-24T00:00:00+08:00"}`
|
||||
if err := os.WriteFile(filepath.Join(dir, appConfigFile), []byte(staleJSON), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile error: %v", err)
|
||||
}
|
||||
|
||||
resetAppConfigCache()
|
||||
provider := NewOAuthProvider(dir, nil)
|
||||
|
||||
// Verify stale clientID was loaded
|
||||
if provider.clientID != "ding-stale-oauth" {
|
||||
t.Fatalf("provider.clientID = %q, want 'ding-stale-oauth'", provider.clientID)
|
||||
}
|
||||
|
||||
// Reset should clear it
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue155V2_OAuthForceLogin_MCPFlagSet_StillResets(t *testing.T) {
|
||||
// Scenario: MCP flag is already set. OAuth --force login should still reset.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
SetClientIDFromMCP("ding-old-mcp-oauth")
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
provider := NewOAuthProvider(dir, nil)
|
||||
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false after reset")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
@@ -127,22 +145,38 @@ type serviceResult struct {
|
||||
ErrorMsg string `json:"errorMsg"`
|
||||
}
|
||||
|
||||
// resetCredentialState clears any stale credential state inherited from
|
||||
// previous login methods (OAuth, PAT, etc.) so that device flow always
|
||||
// starts fresh by fetching clientID from MCP.
|
||||
//
|
||||
// This is a defensive measure: no matter what a prior login wrote to
|
||||
// app.json or runtime globals, device flow will re-fetch from MCP and
|
||||
// set the correct clientIDFromMCP flag, ensuring exchangeCode() uses
|
||||
// the MCP proxy path (which doesn't require clientSecret).
|
||||
func (p *DeviceFlowProvider) resetCredentialState() {
|
||||
p.clientID = ""
|
||||
clientMu.Lock()
|
||||
clientIDFromMCP = false
|
||||
clientMu.Unlock()
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
|
||||
// Ensure we have a valid client ID (fetch from MCP if not available)
|
||||
if p.clientID == "" {
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("client ID not configured, fetching from MCP server")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
// Mark that clientID is from MCP
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
// Defensive reset: clear any stale credential state from previous login
|
||||
// methods (OAuth scan, PAT, etc.) so we always re-fetch from MCP.
|
||||
// This ensures --device login works regardless of what app.json contains.
|
||||
p.resetCredentialState()
|
||||
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
|
||||
const maxAttempts = 3
|
||||
@@ -271,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
|
||||
}
|
||||
|
||||
@@ -412,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:
|
||||
@@ -425,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 {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import "time"
|
||||
|
||||
// MarkAccessTokenStale loads the persisted TokenData, sets ExpiresAt to a past
|
||||
// instant (preserving access_token and refresh_token), and writes it back. The
|
||||
// next OAuthProvider.GetAccessToken call will see IsAccessTokenValid() == false
|
||||
// and proceed to lockedRefresh, exchanging the refresh_token for a fresh
|
||||
// access_token.
|
||||
//
|
||||
// Use this only when the server has rejected the current access_token but the
|
||||
// local expiry has not yet elapsed (zombie token scenario). It does not delete
|
||||
// any token material and is safe to call concurrently — actual refresh is
|
||||
// serialized by lockedRefresh's dual-layer locking.
|
||||
//
|
||||
// Returns the original load error when there is no usable token on disk; a
|
||||
// nil error when there is no access_token to invalidate (no-op).
|
||||
func MarkAccessTokenStale(configDir string) error {
|
||||
data, err := LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil || data.AccessToken == "" {
|
||||
return nil
|
||||
}
|
||||
data.ExpiresAt = time.Now().Add(-1 * time.Minute)
|
||||
return SaveTokenData(configDir, data)
|
||||
}
|
||||
@@ -55,6 +55,16 @@ func NewOAuthProvider(configDir string, logger *slog.Logger) *OAuthProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// resetCredentialState clears any stale credential state inherited from
|
||||
// previous login methods so that OAuth flow always starts fresh by
|
||||
// fetching clientID from MCP.
|
||||
func (p *OAuthProvider) resetCredentialState() {
|
||||
p.clientID = ""
|
||||
clientMu.Lock()
|
||||
clientIDFromMCP = false
|
||||
clientMu.Unlock()
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) output() io.Writer {
|
||||
if p != nil && p.Output != nil {
|
||||
return p.Output
|
||||
@@ -75,6 +85,10 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("access_token still valid, skipping login")
|
||||
}
|
||||
// Even on early return, persist custom app credentials if provided
|
||||
// via --client-id/--client-secret flags. Without this, the flags
|
||||
// are only in runtime globals and lost when the process exits.
|
||||
p.persistAppConfigIfNeeded()
|
||||
return data, nil
|
||||
}
|
||||
// Case 2: refresh using refresh_token (with lock to prevent concurrent refresh).
|
||||
@@ -84,6 +98,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
refreshed, rErr := p.lockedRefresh(ctx)
|
||||
if rErr == nil {
|
||||
p.persistAppConfigIfNeeded()
|
||||
return refreshed, nil
|
||||
}
|
||||
if p.logger != nil {
|
||||
@@ -94,21 +109,22 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
|
||||
// Fall through: full browser OAuth flow.
|
||||
// Ensure we have a valid client ID (fetch from MCP if not available)
|
||||
if p.clientID == "" {
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("client ID not configured, fetching from MCP server")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
// Mark that clientID is from MCP, so we use MCP OAuth endpoints
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
// Defensive reset: clear any stale credential state from previous login
|
||||
// methods so we always re-fetch clientID from MCP. This ensures
|
||||
// --force login works regardless of what app.json contains.
|
||||
p.resetCredentialState()
|
||||
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
|
||||
// Find a free port for the callback server.
|
||||
@@ -485,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
|
||||
}
|
||||
|
||||
@@ -616,9 +635,8 @@ func (p *OAuthProvider) persistAppConfigIfNeeded() {
|
||||
return
|
||||
}
|
||||
|
||||
// Only persist if they differ from environment/default values
|
||||
envID := getEnvClientID()
|
||||
if clientID == envID || clientID == DefaultClientID {
|
||||
// Skip if using default placeholder credentials
|
||||
if clientID == DefaultClientID {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
// TestMain isolates the on-disk keychain storage to a process-wide
|
||||
// temporary directory for the entire internal/auth test binary so that
|
||||
// SaveTokenData/DeleteTokenData calls in these tests can never write to
|
||||
// the developer's real keychain location, preventing cross-package leaks
|
||||
// when go test runs packages in parallel.
|
||||
func TestMain(m *testing.M) {
|
||||
tmpDir, err := os.MkdirTemp("", "dws-auth-test-keychain-")
|
||||
if err != nil {
|
||||
panic("create test keychain tempdir: " + err.Error())
|
||||
}
|
||||
if err := os.Setenv(keychain.StorageDirEnv, tmpDir); err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -20,9 +20,12 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
// cleanupKeychain removes test data from keychain after test completes.
|
||||
// cleanupKeychain isolates keychain state to a per-test temporary directory
|
||||
// so that concurrent test packages (notably internal/app) don't read tokens
|
||||
// written by these tests, and removes test data on completion.
|
||||
func cleanupKeychain(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
t.Cleanup(func() {
|
||||
_ = keychain.Remove(keychain.Service, keychain.AccountToken)
|
||||
})
|
||||
|
||||
+146
-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,9 +266,19 @@ 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)
|
||||
}
|
||||
|
||||
// Surface Portal-side merge warnings (dropped envelopes, dangling serverDeps,
|
||||
// dangling toolOverrides.*.serverOverride) to stderr so configuration drift
|
||||
// is visible at the first `dws cache refresh` / cold start after Portal
|
||||
// publishes a broken envelope. Non-fatal: discovery continues with the
|
||||
// accepted subset. See plan fix-wukong-discovery-missing-servers Phase 4.3.
|
||||
logDiscoveryWarnings(response.Metadata.Warnings)
|
||||
|
||||
servers := market.NormalizeServers(response, "live_market")
|
||||
_ = store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers})
|
||||
|
||||
@@ -280,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))
|
||||
}
|
||||
|
||||
@@ -298,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
|
||||
}
|
||||
|
||||
@@ -306,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 {
|
||||
@@ -328,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,
|
||||
@@ -340,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
|
||||
@@ -354,3 +482,19 @@ func (l EnvironmentLoader) lookup(key string) (string, bool) {
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// logDiscoveryWarnings prints Portal-side merge warnings via slog (stderr).
|
||||
// No-op when the response carries an empty / nil Warnings slice — which is the
|
||||
// common case for older Portal builds that don't populate the field.
|
||||
func logDiscoveryWarnings(warnings []market.ListWarning) {
|
||||
if len(warnings) == 0 {
|
||||
return
|
||||
}
|
||||
for _, w := range warnings {
|
||||
slog.Warn("discovery: merge warning from Portal",
|
||||
"product", w.ProductID,
|
||||
"reason", w.Reason,
|
||||
"detail", w.Detail,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
-9
@@ -19,6 +19,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
@@ -122,19 +123,51 @@ func readStdinBounded() (string, error) {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// looksLikeFilePath returns true when value should be interpreted as the
|
||||
// `@<path>` file-injection syntax. Heuristic: value must start with '@', and
|
||||
// the character right after '@' must be ASCII (letter, digit, or one of the
|
||||
// common path-prefix characters: . / ~ _ -). This rules out mistaken matches
|
||||
// for natural-language messages that happen to start with '@' followed by
|
||||
// non-ASCII text — e.g. "@所有人" should be a chat mention, not a file path.
|
||||
func looksLikeFilePath(value string) bool {
|
||||
if !strings.HasPrefix(value, "@") || len(value) < 2 {
|
||||
return false
|
||||
}
|
||||
rest := value[1:]
|
||||
if rest == "-" {
|
||||
return true // @- = stdin
|
||||
}
|
||||
first := rune(rest[0])
|
||||
if first > unicode.MaxASCII {
|
||||
// First byte is part of a multi-byte rune (e.g. Chinese) — not a path.
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case first >= 'A' && first <= 'Z',
|
||||
first >= 'a' && first <= 'z',
|
||||
first >= '0' && first <= '9',
|
||||
first == '.', first == '/', first == '~', first == '_', first == '-':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ReadFileArg reads the contents of a file referenced by the @filename syntax.
|
||||
// Returns the original value unchanged if it does not start with "@".
|
||||
// Returns the original value unchanged if it does not start with "@" or is
|
||||
// otherwise not a file-path-shaped value (e.g. "@所有人" is treated as plain
|
||||
// text, not a path).
|
||||
// Returns an error if the file cannot be read or exceeds the size limit.
|
||||
//
|
||||
// Note: @- (stdin) is NOT handled here; use ResolveInputSource instead.
|
||||
func ReadFileArg(value string) (string, bool, error) {
|
||||
if !strings.HasPrefix(value, "@") {
|
||||
// Preserve the historical bare-"@" behaviour (empty filename → error).
|
||||
if value == "@" {
|
||||
return "", false, apperrors.NewValidation("@file: filename must not be empty")
|
||||
}
|
||||
if !looksLikeFilePath(value) {
|
||||
return value, false, nil
|
||||
}
|
||||
path := value[1:]
|
||||
if path == "" {
|
||||
return "", false, apperrors.NewValidation("@file: filename must not be empty")
|
||||
}
|
||||
// @- is stdin, not a file — callers should use ResolveInputSource.
|
||||
if path == "-" {
|
||||
return value, false, nil
|
||||
@@ -156,14 +189,17 @@ func ReadFileArg(value string) (string, bool, error) {
|
||||
//
|
||||
// The flagName parameter is used only for error messages and StdinGuard tracking.
|
||||
func ResolveInputSource(value string, flagName string, guard *StdinGuard) (string, error) {
|
||||
if !strings.HasPrefix(value, "@") {
|
||||
// Preserve the historical bare-"@" behaviour (empty filename → error).
|
||||
if value == "@" {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s: @file filename must not be empty", flagName))
|
||||
}
|
||||
if !looksLikeFilePath(value) {
|
||||
// Pass through natural-language strings that happen to start with
|
||||
// '@' (e.g. "@所有人 早上好") so they reach the MCP payload intact.
|
||||
return value, nil
|
||||
}
|
||||
|
||||
path := value[1:]
|
||||
if path == "" {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s: @file filename must not be empty", flagName))
|
||||
}
|
||||
|
||||
// @- reads from stdin.
|
||||
if path == "-" {
|
||||
|
||||
@@ -47,6 +47,38 @@ func TestReadFileArgPlainValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadFileArgChineseAtMention guards the @所有人-style mentions that the
|
||||
// chat bot Webhook tests rely on: an '@' followed by non-ASCII text must be
|
||||
// treated as a literal message, not as the @file injection syntax.
|
||||
func TestReadFileArgChineseAtMention(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []string{
|
||||
"@所有人 这是 @ 所有人 的消息",
|
||||
"@张三",
|
||||
"@A 但接下来都是中文@测试",
|
||||
}
|
||||
for _, in := range cases {
|
||||
val, isFile, err := ReadFileArg(in)
|
||||
if in == "@A 但接下来都是中文@测试" {
|
||||
// '@A' starts with ASCII, treated as path → expect file error
|
||||
if err == nil {
|
||||
t.Errorf("@A... should attempt file lookup; got val=%q isFile=%v", val, isFile)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("%q: unexpected error %v", in, err)
|
||||
continue
|
||||
}
|
||||
if isFile {
|
||||
t.Errorf("%q: should be plain text, got isFile=true", in)
|
||||
}
|
||||
if val != in {
|
||||
t.Errorf("%q: got %q", in, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileArgReadsFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -14,37 +14,18 @@
|
||||
package cobracmd
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
)
|
||||
|
||||
const overridePriorityAnnotation = "dws.override-priority"
|
||||
|
||||
// SetOverridePriority sets the override priority annotation on cmd.
|
||||
// SetOverridePriority delegates to cmdutil.SetOverridePriority so internal and
|
||||
// external call sites share one annotation key.
|
||||
func SetOverridePriority(cmd *cobra.Command, priority int) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[overridePriorityAnnotation] = strconv.Itoa(priority)
|
||||
cmdutil.SetOverridePriority(cmd, priority)
|
||||
}
|
||||
|
||||
// OverridePriority returns the override priority annotation value, or 0.
|
||||
// OverridePriority delegates to cmdutil.OverridePriority.
|
||||
func OverridePriority(cmd *cobra.Command) int {
|
||||
if cmd == nil || cmd.Annotations == nil {
|
||||
return 0
|
||||
}
|
||||
raw := strings.TrimSpace(cmd.Annotations[overridePriorityAnnotation])
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
return cmdutil.OverridePriority(cmd)
|
||||
}
|
||||
|
||||
@@ -14,18 +14,35 @@
|
||||
package compat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// runtimeDefaultWhitelist is the closed set of placeholders v3 supports for
|
||||
// CLIFlagOverride.RuntimeDefault. Placeholders outside this set emit a
|
||||
// warning at command-build time and are ignored at invocation time. See
|
||||
// discovery-schema-v3 §2.3.
|
||||
var runtimeDefaultWhitelist = map[string]bool{
|
||||
"$currentUserId": true,
|
||||
"$unionId": true,
|
||||
"$corpId": true,
|
||||
"$now": true,
|
||||
"$today": true,
|
||||
}
|
||||
|
||||
// BuildDynamicCommands generates cobra commands from servers.json CLIOverlay metadata.
|
||||
// Each server with non-skip CLIOverlay gets a top-level command with groups and
|
||||
// tool overrides translated into subcommands with proper flag bindings and transforms.
|
||||
@@ -47,10 +64,6 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
if cli.Skip {
|
||||
continue
|
||||
}
|
||||
if len(cli.ToolOverrides) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// §1.1: cli.command → top-level command name
|
||||
cmdName := strings.TrimSpace(cli.Command)
|
||||
if cmdName == "" {
|
||||
@@ -60,11 +73,35 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
continue
|
||||
}
|
||||
|
||||
// §v3.2.6: CLIOverlay.RedirectTo turns the entire top-level product
|
||||
// into a stub that prints "Please use: dws <target>".
|
||||
if target := strings.TrimSpace(cli.RedirectTo); target != "" {
|
||||
stub := buildOverlayRedirect(cmdName, cli.Description, target)
|
||||
if cli.Hidden {
|
||||
stub.Hidden = true
|
||||
}
|
||||
// Mark envelope provenance so overlay registrants can tell this
|
||||
// redirect stub apart from a helper fallback carrying the same
|
||||
// name; see cmdutil.SourceAnnotation.
|
||||
cmdutil.MarkEnvelopeSource(stub)
|
||||
built = append(built, builtCmd{cmd: stub, parent: strings.TrimSpace(cli.Parent)})
|
||||
continue
|
||||
}
|
||||
|
||||
if len(cli.ToolOverrides) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
rootCmd := NewGroupCommand(cmdName, cli.Description)
|
||||
// §1.5: cli.hidden → entire service hidden
|
||||
if cli.Hidden {
|
||||
rootCmd.Hidden = true
|
||||
}
|
||||
// Mark envelope provenance so edition overlays can distinguish this
|
||||
// dynamic root from a same-named helper fallback when deciding
|
||||
// whether to merge hardcoded leaves or evict and replace. See
|
||||
// cmdutil.SourceAnnotation and the wukong overlay's RegisterProducts.
|
||||
cmdutil.MarkEnvelopeSource(rootCmd)
|
||||
|
||||
// Build detail index for this server: toolName → DetailTool
|
||||
detailIndex := buildDetailIndex(detailsByID[strings.TrimSpace(cli.ID)])
|
||||
@@ -93,6 +130,13 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
cliName = deriveCommandName(toolName, cli.Prefixes)
|
||||
}
|
||||
|
||||
// §P2.redirect: redirectTo turns this entry into a stub.
|
||||
if target := strings.TrimSpace(override.RedirectTo); target != "" {
|
||||
redirect := buildRedirectCommand(cliName, override.Description, target)
|
||||
attachToGroup(rootCmd, override.Group, groupCmds, redirect)
|
||||
continue
|
||||
}
|
||||
|
||||
bindings, normalizer := buildOverrideBindings(override)
|
||||
|
||||
// Resolve Short/Long from Detail API toolTitle/toolDesc;
|
||||
@@ -111,15 +155,36 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
}
|
||||
}
|
||||
|
||||
// ServerOverride routes this leaf's tool invocation to a different
|
||||
// product's MCP server; fall back to the enclosing overlay's ID.
|
||||
canonicalProduct := strings.TrimSpace(override.ServerOverride)
|
||||
if canonicalProduct == "" {
|
||||
canonicalProduct = strings.TrimSpace(cli.ID)
|
||||
}
|
||||
|
||||
route := Route{
|
||||
Use: cliName,
|
||||
Short: short,
|
||||
Long: long,
|
||||
Use: cliName,
|
||||
// CLIAliases register additional cobra command aliases for the
|
||||
// same MCP tool. Empty / nil means no extra names.
|
||||
Aliases: append([]string(nil), override.CLIAliases...),
|
||||
Short: short,
|
||||
Long: long,
|
||||
// Preserve left-side indentation: cobra's Examples template
|
||||
// renders {{.Example}} verbatim, and hardcoded helper commands
|
||||
// rely on a 2-space prefix to look indented under "Examples:".
|
||||
// Only trim trailing whitespace/newlines so envelope JSON can
|
||||
// safely carry a closing "\n" without doubling the blank line.
|
||||
Example: strings.TrimRight(override.Example, " \t\r\n"),
|
||||
Target: Target{
|
||||
CanonicalProduct: strings.TrimSpace(cli.ID),
|
||||
CanonicalProduct: canonicalProduct,
|
||||
Tool: toolName,
|
||||
},
|
||||
Bindings: bindings,
|
||||
Bindings: bindings,
|
||||
// §pipeline: when the envelope declares a multi-step
|
||||
// orchestration, NewDirectCommand reroutes RunE into the
|
||||
// pipeline executor instead of the single-tool flow. The
|
||||
// CLIName / Group / Flags surface above still applies.
|
||||
Pipeline: append([]market.PipelineStep(nil), override.Pipeline...),
|
||||
Normalizer: normalizer,
|
||||
}
|
||||
|
||||
@@ -128,6 +193,9 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
route.Normalizer = chainSensitiveNormalizer(normalizer)
|
||||
}
|
||||
|
||||
// §v3.2.5: outputFormat.rename/drop/columns post-processing.
|
||||
route.OutputTransform = buildOutputTransform(override.OutputFormat)
|
||||
|
||||
cmd := NewDirectCommand(route, runner)
|
||||
|
||||
// Enrich flags with typed parameters from Detail API toolRequest JSON Schema.
|
||||
@@ -135,13 +203,25 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
buildFlagsFromDetailSchema(cmd, dt.ToolRequest, override.Flags)
|
||||
}
|
||||
|
||||
// §P2.flagconstraints: must run AFTER schema enrichment because the
|
||||
// target flags may be registered lazily by buildFlagsFromDetailSchema.
|
||||
applyFlagConstraints(cmd, override)
|
||||
|
||||
// §1.4: Add to the right parent group
|
||||
groupName := strings.TrimSpace(override.Group)
|
||||
if groupName != "" {
|
||||
parent := resolveNestedGroup(rootCmd, groupName, groupCmds)
|
||||
parent.AddCommand(cmd)
|
||||
} else {
|
||||
rootCmd.AddCommand(cmd)
|
||||
attachToGroup(rootCmd, override.Group, groupCmds, cmd)
|
||||
}
|
||||
|
||||
// §P2.hints: attach hint stub commands registered on the overlay.
|
||||
if len(cli.Hints) > 0 {
|
||||
hintNames := make([]string, 0, len(cli.Hints))
|
||||
for name := range cli.Hints {
|
||||
hintNames = append(hintNames, name)
|
||||
}
|
||||
sort.Strings(hintNames)
|
||||
for _, name := range hintNames {
|
||||
def := cli.Hints[name]
|
||||
hint := buildHintCommand(name, def)
|
||||
attachToGroup(rootCmd, def.Group, groupCmds, hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,10 +236,18 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
for _, b := range built {
|
||||
if b.parent == "" {
|
||||
name := b.cmd.Name()
|
||||
if _, exists := topLevel[name]; !exists {
|
||||
if existing, exists := topLevel[name]; exists {
|
||||
// Multiple servers contribute the same top-level command
|
||||
// (e.g. group-chat and im both register `dws chat`). Move
|
||||
// the incoming command's *children* into the existing top-
|
||||
// level command instead of attaching the whole command (which
|
||||
// would create `dws chat chat` because attachOrMerge would
|
||||
// AddCommand(b.cmd) when no same-named sub exists).
|
||||
mergeSubcommandsInto(existing, b.cmd)
|
||||
} else {
|
||||
topOrder = append(topOrder, name)
|
||||
topLevel[name] = b.cmd
|
||||
}
|
||||
topLevel[name] = b.cmd
|
||||
} else {
|
||||
children = append(children, b)
|
||||
}
|
||||
@@ -168,12 +256,16 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
if parent, ok := topLevel[child.parent]; ok {
|
||||
attachOrMerge(parent, child.cmd)
|
||||
} else {
|
||||
// Parent not found among dynamic commands; emit as top-level.
|
||||
// Parent not found among dynamic commands; emit as top-level,
|
||||
// merging into an existing same-named top-level command if one
|
||||
// is already registered (same reasoning as the loop above).
|
||||
name := child.cmd.Name()
|
||||
if _, exists := topLevel[name]; !exists {
|
||||
if existing, exists := topLevel[name]; exists {
|
||||
mergeSubcommandsInto(existing, child.cmd)
|
||||
} else {
|
||||
topOrder = append(topOrder, name)
|
||||
topLevel[name] = child.cmd
|
||||
}
|
||||
topLevel[name] = child.cmd
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,10 +295,12 @@ type toolRequestSchema struct {
|
||||
}
|
||||
|
||||
type toolRequestProp struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Default string `json:"default,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Default string `json:"default,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Enum []string `json:"enum,omitempty"`
|
||||
}
|
||||
|
||||
// buildFlagsFromDetailSchema adds properly-typed cobra flags to cmd based on
|
||||
@@ -238,6 +332,15 @@ func buildFlagsFromDetailSchema(cmd *cobra.Command, schemaJSON string, flagOverr
|
||||
for _, key := range keys {
|
||||
prop := schema.Properties[key]
|
||||
|
||||
// Skip properties that are bound as positional arguments; they are
|
||||
// collected from cobra args rather than flags. For dual-mode positional
|
||||
// bindings (envelope: positional + alias/aliases), ApplyBindings has
|
||||
// already registered the alias flags, so we should not re-register
|
||||
// them here from the MCP detail schema.
|
||||
if ov, ok := flagOverrides[key]; ok && ov.Positional {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine flag name: prefer alias from CLIFlagOverride, else kebab-case.
|
||||
flagName := toKebabCase(key)
|
||||
if ov, ok := flagOverrides[key]; ok && strings.TrimSpace(ov.Alias) != "" {
|
||||
@@ -282,6 +385,17 @@ func buildFlagsFromDetailSchema(cmd *cobra.Command, schemaJSON string, flagOverr
|
||||
cmd.Flags().String(flagName, defaultVal, help)
|
||||
}
|
||||
|
||||
// Carry schema "format" / "enum" hints onto the cobra flag via
|
||||
// pflag annotations so PreParse handlers (e.g. StickyHandler)
|
||||
// can reason about whether a glued suffix looks like a real
|
||||
// value. The annotation keys are read by FlagInfoFromCommand.
|
||||
if prop.Format != "" {
|
||||
_ = cmd.Flags().SetAnnotation(flagName, "x-cli-format", []string{prop.Format})
|
||||
}
|
||||
if len(prop.Enum) > 0 {
|
||||
_ = cmd.Flags().SetAnnotation(flagName, "x-cli-enum", append([]string{}, prop.Enum...))
|
||||
}
|
||||
|
||||
if requiredSet[key] {
|
||||
_ = cmd.MarkFlagRequired(flagName)
|
||||
}
|
||||
@@ -389,6 +503,24 @@ func resolveNestedGroup(root *cobra.Command, groupPath string, registry map[stri
|
||||
return ensureNestedGroup(root, groupPath, groupPath, registry)
|
||||
}
|
||||
|
||||
// mergeSubcommandsInto moves all sub-commands of src into dst, using
|
||||
// attachOrMerge so subtree merges happen recursively. src itself is left
|
||||
// empty after the call. Used when two envelope entries register the same
|
||||
// top-level command (e.g. group-chat and im both `cli.command="chat"`):
|
||||
// we want their *children* to coexist under one chat root, not have one
|
||||
// nested inside the other.
|
||||
func mergeSubcommandsInto(dst, src *cobra.Command) {
|
||||
if dst == nil || src == nil {
|
||||
return
|
||||
}
|
||||
subs := make([]*cobra.Command, len(src.Commands()))
|
||||
copy(subs, src.Commands())
|
||||
for _, sub := range subs {
|
||||
src.RemoveCommand(sub)
|
||||
attachOrMerge(dst, sub)
|
||||
}
|
||||
}
|
||||
|
||||
// attachOrMerge adds child as a sub-command of parent. If parent already has a
|
||||
// sub-command with the same Name(), the two are merged recursively: child's
|
||||
// sub-commands are moved onto the existing one and child itself is discarded.
|
||||
@@ -443,30 +575,64 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
var bindings []FlagBinding
|
||||
type transformEntry struct {
|
||||
paramName string
|
||||
mapsTo string
|
||||
transform string
|
||||
transformArgs map[string]any
|
||||
}
|
||||
var transforms []transformEntry
|
||||
// mapsToRoutes captures flags that only need value-routing (no transform)
|
||||
// — e.g. a literal --content flag that mapsTo "markdown". The dispatch
|
||||
// loop moves params[paramName] → params[mapsTo] after CLI binding.
|
||||
type mapsToRoute struct {
|
||||
paramName string
|
||||
mapsTo string
|
||||
}
|
||||
var mapsToRoutes []mapsToRoute
|
||||
type envDefaultEntry struct {
|
||||
paramName string
|
||||
envVar string
|
||||
}
|
||||
var envDefaults []envDefaultEntry
|
||||
type hiddenDefaultEntry struct {
|
||||
// defaultInjectEntry captures envelope flag.default values that must be
|
||||
// injected into the MCP body when the user omits the flag. v3.2 widened
|
||||
// this from hidden-only to all flags so that visible flags carrying a
|
||||
// default (e.g. oa list-forms cursor=0 / pageSize=100) match the
|
||||
// hardcoded helper command behavior of `mustGetFlag(cobra default) →
|
||||
// body`. The kind drives typed coercion at injection time so a
|
||||
// `type: int` envelope default reaches MCP as `int(0)`, not string `"0"`.
|
||||
type defaultInjectEntry struct {
|
||||
paramName string
|
||||
defaultValue string
|
||||
kind ValueKind
|
||||
}
|
||||
var hiddenDefaults []hiddenDefaultEntry
|
||||
var defaultInjects []defaultInjectEntry
|
||||
type runtimeDefaultEntry struct {
|
||||
paramName string
|
||||
placeholder string
|
||||
}
|
||||
var runtimeDefaults []runtimeDefaultEntry
|
||||
type omitEntry struct {
|
||||
paramName string
|
||||
mode string // "empty" (default) | "zero" | "never"
|
||||
}
|
||||
omits := make(map[string]omitEntry, len(paramNames))
|
||||
|
||||
for _, paramName := range paramNames {
|
||||
flagOverride := override.Flags[paramName]
|
||||
|
||||
// §2.2: flag name from alias, fallback to kebab-case of param name
|
||||
flagName := strings.TrimSpace(flagOverride.Alias)
|
||||
if flagName == "" {
|
||||
// §2.2: flag name from alias, fallback to kebab-case of param name.
|
||||
// For pure positional bindings (no alias declared) we deliberately
|
||||
// leave FlagName empty so ApplyBindings / NewDirectCommand can
|
||||
// distinguish "envelope wants flag-or-positional dual entry" from
|
||||
// "envelope only wants positional". Auto-deriving a flag name here
|
||||
// would otherwise leak a redundant `--<paramName>` flag and confuse
|
||||
// the dual-mode detection.
|
||||
explicitAlias := strings.TrimSpace(flagOverride.Alias)
|
||||
flagName := explicitAlias
|
||||
if flagName == "" && !flagOverride.Positional {
|
||||
flagName = compatFlagName(paramName)
|
||||
}
|
||||
if flagName == "" {
|
||||
if flagName == "" && !flagOverride.Positional {
|
||||
flagName = paramName
|
||||
}
|
||||
|
||||
@@ -475,23 +641,74 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
continue
|
||||
}
|
||||
|
||||
binding := FlagBinding{
|
||||
FlagName: flagName,
|
||||
Property: paramName,
|
||||
Kind: ValueString,
|
||||
Usage: paramName,
|
||||
// §2.2 (aliases): deduplicate additional hidden aliases against the
|
||||
// primary name + reserved names. Preserves envelope declaration order
|
||||
// so CLI precedence (primary > Alias > Aliases[0..n]) is deterministic.
|
||||
var extraAliases []string
|
||||
if len(flagOverride.Aliases) > 0 {
|
||||
seen := map[string]bool{"json": true, "params": true}
|
||||
if flagName != "" {
|
||||
seen[flagName] = true
|
||||
}
|
||||
extraAliases = make([]string, 0, len(flagOverride.Aliases))
|
||||
for _, a := range flagOverride.Aliases {
|
||||
a = strings.TrimSpace(a)
|
||||
if a == "" || seen[a] {
|
||||
continue
|
||||
}
|
||||
seen[a] = true
|
||||
extraAliases = append(extraAliases, a)
|
||||
}
|
||||
}
|
||||
|
||||
// §2.5: hidden flag with default
|
||||
if flagOverride.Hidden {
|
||||
// Hidden flags are still added but marked hidden.
|
||||
// They are auto-populated with their default value via the normalizer.
|
||||
if flagOverride.Default != "" {
|
||||
hiddenDefaults = append(hiddenDefaults, hiddenDefaultEntry{
|
||||
paramName: paramName,
|
||||
defaultValue: flagOverride.Default,
|
||||
})
|
||||
}
|
||||
// Usage defaults to paramName but an explicit Description on the
|
||||
// overlay wins (it also beats the Detail API's toolDesc during flag
|
||||
// enrichment because buildFlagsFromDetailSchema preserves overlay usage).
|
||||
usage := paramName
|
||||
if desc := strings.TrimSpace(flagOverride.Description); desc != "" {
|
||||
usage = desc
|
||||
}
|
||||
|
||||
binding := FlagBinding{
|
||||
FlagName: flagName,
|
||||
Aliases: extraAliases,
|
||||
// §pipeline: PipelineLocal flags (e.g. `--output` in the
|
||||
// sheet export pipeline) are CLI-side only — they appear in
|
||||
// --help and are bindable, but CollectBindings skips them so
|
||||
// the value never reaches MCP params. The pipeline executor
|
||||
// reads them via extractFlagValuesByAlias.
|
||||
PipelineLocal: flagOverride.PipelineLocal,
|
||||
Short: strings.TrimSpace(flagOverride.Shorthand),
|
||||
Property: paramName,
|
||||
Kind: kindFromTypeName(flagOverride.Type),
|
||||
Usage: usage,
|
||||
// §P1: Required is preserved for positional bindings too. For
|
||||
// pure positional, cobra arity (MinimumNArgs) enforces presence
|
||||
// at parse time. For dual-mode positional (positional + alias),
|
||||
// validateRequiredPositionalBindings closes the loop in RunE
|
||||
// after both flag and positional injection, so MarkFlagRequired
|
||||
// is intentionally avoided.
|
||||
Required: flagOverride.Required,
|
||||
// §2.4: Default drives both cobra's --help "(default ...)"
|
||||
// rendering and (since v3.2) MCP body injection when the user
|
||||
// omits the flag. CollectBindings still gates writes by
|
||||
// user-changed flags, so user-provided values always win; the
|
||||
// normalizer's defaultInjects loop only fills missing keys.
|
||||
Default: flagOverride.Default,
|
||||
Positional: flagOverride.Positional,
|
||||
PositionalIndex: flagOverride.PositionalIndex,
|
||||
}
|
||||
|
||||
// §v3.2: any non-empty default — hidden or visible — gets injected
|
||||
// when the user omits the flag. Earlier versions gated this on
|
||||
// flagOverride.Hidden which left visible flags with `"default": "0"`
|
||||
// (e.g. oa list-forms cursor) silently absent from the MCP body.
|
||||
if flagOverride.Default != "" {
|
||||
defaultInjects = append(defaultInjects, defaultInjectEntry{
|
||||
paramName: paramName,
|
||||
defaultValue: flagOverride.Default,
|
||||
kind: binding.Kind,
|
||||
})
|
||||
}
|
||||
|
||||
bindings = append(bindings, binding)
|
||||
@@ -499,9 +716,19 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
if flagOverride.Transform != "" {
|
||||
transforms = append(transforms, transformEntry{
|
||||
paramName: paramName,
|
||||
mapsTo: strings.TrimSpace(flagOverride.MapsTo),
|
||||
transform: flagOverride.Transform,
|
||||
transformArgs: flagOverride.TransformArgs,
|
||||
})
|
||||
} else if mt := strings.TrimSpace(flagOverride.MapsTo); mt != "" {
|
||||
// mapsTo without transform: just route the literal value into a
|
||||
// different MCP parameter slot. Common case is --content (literal
|
||||
// string) mapping to MCP parameter markdown, alongside a sibling
|
||||
// --content-file (transform: file_read) mapping to the same slot.
|
||||
mapsToRoutes = append(mapsToRoutes, mapsToRoute{
|
||||
paramName: paramName,
|
||||
mapsTo: mt,
|
||||
})
|
||||
}
|
||||
if flagOverride.EnvDefault != "" {
|
||||
envDefaults = append(envDefaults, envDefaultEntry{
|
||||
@@ -509,10 +736,23 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
envVar: flagOverride.EnvDefault,
|
||||
})
|
||||
}
|
||||
if rd := strings.TrimSpace(flagOverride.RuntimeDefault); rd != "" {
|
||||
if !runtimeDefaultWhitelist[rd] {
|
||||
fmt.Fprintf(os.Stderr, "[discovery] runtimeDefault: unknown placeholder %q on %s; ignoring\n", rd, paramName)
|
||||
} else {
|
||||
runtimeDefaults = append(runtimeDefaults, runtimeDefaultEntry{
|
||||
paramName: paramName,
|
||||
placeholder: rd,
|
||||
})
|
||||
}
|
||||
}
|
||||
if mode := strings.TrimSpace(flagOverride.OmitWhen); mode != "" && mode != "empty" {
|
||||
omits[paramName] = omitEntry{paramName: paramName, mode: mode}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need a normalizer: transforms, env defaults, hidden defaults,
|
||||
// or dotted property paths that need nesting.
|
||||
// runtime defaults, omit-when overrides, dotted property paths, or body wrapper.
|
||||
needsDottedNesting := false
|
||||
for _, b := range bindings {
|
||||
if strings.Contains(b.Property, ".") {
|
||||
@@ -520,16 +760,35 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(transforms) == 0 && len(envDefaults) == 0 && len(hiddenDefaults) == 0 && !needsDottedNesting {
|
||||
bodyWrapper := strings.TrimSpace(override.BodyWrapper)
|
||||
if len(transforms) == 0 && len(envDefaults) == 0 && len(defaultInjects) == 0 && len(runtimeDefaults) == 0 && len(omits) == 0 && len(mapsToRoutes) == 0 && !needsDottedNesting && bodyWrapper == "" {
|
||||
return bindings, nil
|
||||
}
|
||||
|
||||
// Build a normalizer that applies hidden defaults + env defaults + transforms + nesting
|
||||
// Build a normalizer that applies default injections + env defaults +
|
||||
// runtime defaults + transforms + mapsTo routing + omitWhen + nesting +
|
||||
// body wrap. Tool-level cobra constraints (MutuallyExclusive /
|
||||
// RequireOneOf) are wired separately via applyFlagConstraints and don't
|
||||
// belong in this closure.
|
||||
normalizer := func(cmd *cobra.Command, params map[string]any) error {
|
||||
// §2.5: Apply hidden flag defaults for parameters not explicitly set
|
||||
for _, hd := range hiddenDefaults {
|
||||
if _, exists := params[hd.paramName]; !exists {
|
||||
params[hd.paramName] = hd.defaultValue
|
||||
// §v3.2: Apply envelope flag.default for parameters not explicitly set.
|
||||
// Coerce by Kind so number-typed schemas don't reject string defaults.
|
||||
for _, di := range defaultInjects {
|
||||
if _, exists := params[di.paramName]; exists {
|
||||
continue
|
||||
}
|
||||
defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(di.kind, di.defaultValue)
|
||||
switch di.kind {
|
||||
case ValueInt:
|
||||
params[di.paramName] = defInt
|
||||
case ValueFloat:
|
||||
params[di.paramName] = defFloat
|
||||
case ValueBool:
|
||||
params[di.paramName] = defBool
|
||||
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
|
||||
params[di.paramName] = defSlice
|
||||
default: // ValueString, ValueJSON, and any unknown kind
|
||||
params[di.paramName] = defStr
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,14 +801,41 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
}
|
||||
}
|
||||
|
||||
// §3: Apply transforms
|
||||
// §v3.2.3: Apply runtime defaults (lowest priority, last default fill).
|
||||
if len(runtimeDefaults) > 0 {
|
||||
resolvers := runtimeDefaultResolvers()
|
||||
for _, rd := range runtimeDefaults {
|
||||
if _, exists := params[rd.paramName]; exists {
|
||||
continue
|
||||
}
|
||||
resolver, ok := resolvers[rd.placeholder]
|
||||
if !ok {
|
||||
// Whitelisted but no provider registered (common on
|
||||
// open-source core). Emit a single warning and move on.
|
||||
fmt.Fprintf(os.Stderr, "[discovery] runtimeDefault: no resolver registered for %s; skipping %s\n", rd.placeholder, rd.paramName)
|
||||
continue
|
||||
}
|
||||
if val, ok := resolver(cmd.Context()); ok && val != "" {
|
||||
params[rd.paramName] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// §3: Apply transforms. When MapsTo is set, the transformed value is
|
||||
// routed to params[MapsTo] and the original params[paramName] is
|
||||
// dropped, so the MCP body carries a single (post-transform) entry
|
||||
// at the target slot.
|
||||
for _, t := range transforms {
|
||||
val, exists := params[t.paramName]
|
||||
if !exists {
|
||||
// For enum_map with _default, apply default even when flag is omitted
|
||||
if t.transform == "enum_map" && t.transformArgs != nil {
|
||||
if defaultVal, hasDefault := t.transformArgs["_default"]; hasDefault {
|
||||
params[t.paramName] = defaultVal
|
||||
target := t.paramName
|
||||
if t.mapsTo != "" {
|
||||
target = t.mapsTo
|
||||
}
|
||||
params[target] = defaultVal
|
||||
}
|
||||
}
|
||||
continue
|
||||
@@ -558,18 +844,176 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params[t.paramName] = transformed
|
||||
if t.mapsTo != "" {
|
||||
params[t.mapsTo] = transformed
|
||||
delete(params, t.paramName)
|
||||
} else {
|
||||
params[t.paramName] = transformed
|
||||
}
|
||||
}
|
||||
|
||||
// §3b: mapsTo-only routes (no transform). Move params[paramName] →
|
||||
// params[mapsTo] verbatim. Common pattern: a literal --content flag
|
||||
// that routes to MCP parameter `markdown`, alongside a sibling
|
||||
// --content-file flag that transforms + routes to the same slot.
|
||||
for _, r := range mapsToRoutes {
|
||||
val, exists := params[r.paramName]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
params[r.mapsTo] = val
|
||||
delete(params, r.paramName)
|
||||
}
|
||||
|
||||
// §v3.2.2: Apply omitWhen — drop keys whose value meets the omit
|
||||
// condition for the declared mode. Default mode "empty" is already
|
||||
// handled implicitly by CollectBindings (empty string / empty slice
|
||||
// never enters params), so we only deal with "zero" and "never".
|
||||
for _, o := range omits {
|
||||
applyOmitWhen(params, o.paramName, o.mode)
|
||||
}
|
||||
|
||||
// Nest dotted property paths: "Body.query" → params["Body"]["query"]
|
||||
nestDottedPaths(params)
|
||||
|
||||
// §P2.bodyWrapper: wrap user-facing params under a single named key.
|
||||
// Internal control keys (prefixed with '_' e.g. _blocked, _yes) stay
|
||||
// at the top level so downstream confirmation logic keeps working.
|
||||
if bodyWrapper != "" {
|
||||
wrapParamsIntoBody(params, bodyWrapper)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return bindings, normalizer
|
||||
}
|
||||
|
||||
// wrapParamsIntoBody moves every non-internal key from params into a new
|
||||
// map stored under params[wrapper]. Internal keys (leading underscore) are
|
||||
// preserved at the top level so the dispatcher / --yes logic still sees
|
||||
// them. If params already contains params[wrapper] it is merged in first.
|
||||
func wrapParamsIntoBody(params map[string]any, wrapper string) {
|
||||
if wrapper == "" {
|
||||
return
|
||||
}
|
||||
body := map[string]any{}
|
||||
if existing, ok := params[wrapper].(map[string]any); ok {
|
||||
for k, v := range existing {
|
||||
body[k] = v
|
||||
}
|
||||
delete(params, wrapper)
|
||||
}
|
||||
for key, value := range params {
|
||||
if strings.HasPrefix(key, "_") {
|
||||
continue
|
||||
}
|
||||
body[key] = value
|
||||
delete(params, key)
|
||||
}
|
||||
params[wrapper] = body
|
||||
}
|
||||
|
||||
// attachToGroup places cmd under the right parent based on the dotted group
|
||||
// path. Empty group means attach directly to the overlay root.
|
||||
func attachToGroup(root *cobra.Command, groupPath string, groupCmds map[string]*cobra.Command, cmd *cobra.Command) {
|
||||
gp := strings.TrimSpace(groupPath)
|
||||
if gp == "" {
|
||||
root.AddCommand(cmd)
|
||||
return
|
||||
}
|
||||
parent := resolveNestedGroup(root, gp, groupCmds)
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
// buildRedirectCommand returns a stub leaf that prints "use: <target>" and
|
||||
// performs no tool invocation. Accepts unknown flags/args so users hitting
|
||||
// the old path get the redirect message instead of a parse error.
|
||||
func buildRedirectCommand(name, description, target string) *cobra.Command {
|
||||
short := strings.TrimSpace(description)
|
||||
if short == "" {
|
||||
short = fmt.Sprintf("moved → %s", target)
|
||||
}
|
||||
cmd := &cobra.Command{
|
||||
Use: name,
|
||||
Short: short,
|
||||
Long: fmt.Sprintf("This command has moved. Please use: %s", target),
|
||||
DisableFlagParsing: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "This command has moved. Please use: %s\n", target)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// buildHintCommand returns a stub sub-command that prints a redirect hint
|
||||
// to the canonical command path declared by the overlay's hintCommands entry.
|
||||
func buildHintCommand(name string, def market.CLIHintDef) *cobra.Command {
|
||||
target := strings.TrimSpace(def.Target)
|
||||
short := strings.TrimSpace(def.Description)
|
||||
if short == "" {
|
||||
if target != "" {
|
||||
short = fmt.Sprintf("hint: use %s", target)
|
||||
} else {
|
||||
short = "hint: see --help for the canonical command"
|
||||
}
|
||||
}
|
||||
cmd := &cobra.Command{
|
||||
Use: name,
|
||||
Short: short,
|
||||
Long: short,
|
||||
DisableFlagParsing: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if target != "" {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Please use: %s\n", target)
|
||||
} else {
|
||||
_ = cmd.Help()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// applyFlagConstraints wires mutuallyExclusive / requireOneOf declarations
|
||||
// onto the cobra command. Unknown flag names are logged and skipped so a
|
||||
// stale/malformed overlay never blocks the entire command tree from building.
|
||||
func applyFlagConstraints(cmd *cobra.Command, override market.CLIToolOverride) {
|
||||
validate := func(names []string) []string {
|
||||
valid := make([]string, 0, len(names))
|
||||
for _, n := range names {
|
||||
n = strings.TrimSpace(n)
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if cmd.Flags().Lookup(n) == nil {
|
||||
fmt.Fprintf(os.Stderr, "[discovery] flag constraint references unknown flag --%s on %q; skipping\n", n, cmd.Name())
|
||||
return nil
|
||||
}
|
||||
valid = append(valid, n)
|
||||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
for _, group := range override.MutuallyExclusive {
|
||||
names := validate(group)
|
||||
if len(names) < 2 {
|
||||
continue
|
||||
}
|
||||
cmd.MarkFlagsMutuallyExclusive(names...)
|
||||
}
|
||||
for _, group := range override.RequireOneOf {
|
||||
names := validate(group)
|
||||
if len(names) < 1 {
|
||||
continue
|
||||
}
|
||||
cmd.MarkFlagsOneRequired(names...)
|
||||
}
|
||||
}
|
||||
|
||||
// chainSensitiveNormalizer wraps a normalizer with --yes confirmation for sensitive operations (§5.1).
|
||||
func chainSensitiveNormalizer(inner Normalizer) Normalizer {
|
||||
return func(cmd *cobra.Command, params map[string]any) error {
|
||||
@@ -618,6 +1062,101 @@ func sortedToolNames(m map[string]market.CLIToolOverride) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
// kindFromTypeName maps the v3 CLIFlagOverride.Type declaration to the
|
||||
// internal FlagBinding.Kind enum. Empty / unknown → ValueString (which keeps
|
||||
// the v2 behaviour where every overlay flag was a plain string).
|
||||
func kindFromTypeName(typeName string) ValueKind {
|
||||
switch strings.TrimSpace(strings.ToLower(typeName)) {
|
||||
case "int", "integer", "number":
|
||||
return ValueInt
|
||||
case "bool", "boolean":
|
||||
return ValueBool
|
||||
case "stringslice", "string_slice", "[]string":
|
||||
return ValueStringSlice
|
||||
case "string", "":
|
||||
return ValueString
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "[discovery] flag type %q not recognised; defaulting to string\n", typeName)
|
||||
return ValueString
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeDefaultResolvers returns the edition-provided resolver map plus
|
||||
// built-in fallbacks for $now / $today (which are trivially local). Overlays
|
||||
// are expected to register the user-identity placeholders; $now / $today are
|
||||
// always available.
|
||||
func runtimeDefaultResolvers() map[string]edition.RuntimeDefaultFn {
|
||||
resolvers := make(map[string]edition.RuntimeDefaultFn, len(runtimeDefaultWhitelist))
|
||||
resolvers["$now"] = func(ctx context.Context) (string, bool) {
|
||||
return fmt.Sprintf("%d", time.Now().UnixMilli()), true
|
||||
}
|
||||
resolvers["$today"] = func(ctx context.Context) (string, bool) {
|
||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
return time.Now().In(loc).Format("2006-01-02"), true
|
||||
}
|
||||
if hooks := edition.Get(); hooks != nil && hooks.RuntimeDefaults != nil {
|
||||
for id, fn := range hooks.RuntimeDefaults() {
|
||||
if fn != nil {
|
||||
resolvers[id] = fn
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolvers
|
||||
}
|
||||
|
||||
// applyOmitWhen drops a key from params when its value meets the omit
|
||||
// condition for the declared mode. "empty" (the default) is handled by
|
||||
// CollectBindings upstream, so this function only processes "zero" and
|
||||
// "never" — "never" is a marker that keeps the zero value explicit, so we
|
||||
// do nothing for it.
|
||||
func applyOmitWhen(params map[string]any, key, mode string) {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "never":
|
||||
return
|
||||
case "zero":
|
||||
val, exists := params[key]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
if isZeroValue(val) {
|
||||
delete(params, key)
|
||||
}
|
||||
default:
|
||||
// "empty" is the default, no-op.
|
||||
}
|
||||
// Emit a trace for anyone debugging envelope behaviour; kept at Debug so
|
||||
// it never leaks into the default CLI output.
|
||||
slog.Debug("applyOmitWhen", "key", key, "mode", mode)
|
||||
}
|
||||
|
||||
func isZeroValue(v any) bool {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case string:
|
||||
return strings.TrimSpace(val) == ""
|
||||
case bool:
|
||||
return !val
|
||||
case int:
|
||||
return val == 0
|
||||
case int64:
|
||||
return val == 0
|
||||
case float64:
|
||||
return val == 0
|
||||
case []any:
|
||||
return len(val) == 0
|
||||
case []string:
|
||||
return len(val) == 0
|
||||
case map[string]any:
|
||||
return len(val) == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// nestDottedPaths converts flat dotted keys in params into nested maps.
|
||||
// Example: params["Body.query"] = "test" → params["Body"] = map{"query": "test"}
|
||||
// If multiple dotted keys share a prefix, they are merged into the same nested map.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
// 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 compat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// buildOverlayRedirect builds a top-level product command whose only behaviour
|
||||
// is printing "Please use: dws <target>" and returning nil. All args/flags are
|
||||
// accepted and ignored so users running the old command path get the redirect
|
||||
// message instead of a parse error.
|
||||
//
|
||||
// See discovery-schema-v3 §2.6 (CLIOverlay.RedirectTo).
|
||||
func buildOverlayRedirect(name, description, target string) *cobra.Command {
|
||||
target = strings.TrimSpace(target)
|
||||
short := strings.TrimSpace(description)
|
||||
if short == "" {
|
||||
if target != "" {
|
||||
short = fmt.Sprintf("moved → %s", target)
|
||||
} else {
|
||||
short = "command relocated; see --help for the canonical path"
|
||||
}
|
||||
}
|
||||
cmd := &cobra.Command{
|
||||
Use: name,
|
||||
Short: short,
|
||||
Long: fmt.Sprintf("This command has moved. Please use: dws %s", target),
|
||||
DisableFlagParsing: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if target == "" {
|
||||
_ = cmd.Help()
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Please use: dws %s\n", target)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// 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 compat
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
)
|
||||
|
||||
// buildOutputTransform compiles a CLIOutputFormat into a payload-shaping
|
||||
// function applied to executor.Result.Response before the formatter runs.
|
||||
// See discovery-schema-v3 §2.5.
|
||||
//
|
||||
// Apply order (deterministic): drop → rename → columns.
|
||||
// Columns filter only takes effect under --format=table (the formatter
|
||||
// consults it via the "_columns" marker key this function injects).
|
||||
// Returns nil when the spec is empty so callers can skip wiring.
|
||||
func buildOutputTransform(spec market.CLIOutputFormat) func(map[string]any) map[string]any {
|
||||
if len(spec.Drop) == 0 && len(spec.Rename) == 0 && len(spec.Columns) == 0 {
|
||||
return nil
|
||||
}
|
||||
dropped := append([]string(nil), spec.Drop...)
|
||||
renamed := make(map[string]string, len(spec.Rename))
|
||||
for k, v := range spec.Rename {
|
||||
renamed[k] = v
|
||||
}
|
||||
columns := append([]string(nil), spec.Columns...)
|
||||
|
||||
return func(resp map[string]any) map[string]any {
|
||||
if resp == nil {
|
||||
return resp
|
||||
}
|
||||
applyDrop(resp, dropped)
|
||||
applyRename(resp, renamed)
|
||||
if len(columns) > 0 {
|
||||
resp["_columns"] = append([]string(nil), columns...)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
}
|
||||
|
||||
// applyDrop removes the named keys at the top level and one level of nested
|
||||
// object. Missing keys are silently ignored. Keys with "." are treated as a
|
||||
// two-part path (parent.child).
|
||||
func applyDrop(m map[string]any, keys []string) {
|
||||
for _, key := range keys {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
delete(m, key)
|
||||
}
|
||||
for _, v := range m {
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, key := range keys {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
delete(inner, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyRename moves fields from src key to dst key at top level and one level
|
||||
// of nested object. Collisions overwrite silently. Missing src keys are
|
||||
// no-ops.
|
||||
func applyRename(m map[string]any, mapping map[string]string) {
|
||||
if len(mapping) == 0 {
|
||||
return
|
||||
}
|
||||
// First pass: top level.
|
||||
for src, dst := range mapping {
|
||||
if src == "" || dst == "" || src == dst {
|
||||
continue
|
||||
}
|
||||
if v, ok := m[src]; ok {
|
||||
m[dst] = v
|
||||
delete(m, src)
|
||||
}
|
||||
}
|
||||
// Second pass: one level nested.
|
||||
for _, v := range m {
|
||||
inner, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for src, dst := range mapping {
|
||||
if src == "" || dst == "" || src == dst {
|
||||
continue
|
||||
}
|
||||
if val, ok := inner[src]; ok {
|
||||
inner[dst] = val
|
||||
delete(inner, src)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
// 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 compat — pipeline executor for CLIToolOverride.Pipeline.
|
||||
//
|
||||
// A pipeline turns a single CLI command into an ordered sequence of MCP
|
||||
// tool calls plus optional HTTP-download sinks, declared entirely in the
|
||||
// envelope JSON. Use cases:
|
||||
//
|
||||
// 1. submit-job + poll-status + download-result patterns (the canonical
|
||||
// example: `dws sheet export --node X --output PATH` calls
|
||||
// submit_export_job → query_export_job (poll until status=done) →
|
||||
// HTTP GET downloadUrl → write to PATH).
|
||||
// 2. compose-then-update flows where step 2's args reference step 1's
|
||||
// response.
|
||||
//
|
||||
// Templates supported in PipelineStep.Args / DownloadURLField:
|
||||
//
|
||||
// $flag.<aliasName> — value of the user's CLI flag whose alias
|
||||
// equals <aliasName>
|
||||
// $step.<idx>.<dotPath> — field from a prior step's response
|
||||
// literal string — passed through unchanged
|
||||
//
|
||||
// Limitations (intentional, to keep the executor small):
|
||||
// - No conditional branching: steps run unconditionally in order.
|
||||
// - No retry-on-error: the pipeline aborts on the first runner error.
|
||||
// - PollUntil compares as strings; numeric/boolean comparisons stringify.
|
||||
// - Download step uses the standard library net/http with no custom
|
||||
// timeout (relies on the user's Ctrl-C).
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
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/market"
|
||||
)
|
||||
|
||||
// pipelineCtx carries flag values + accumulated step responses through
|
||||
// the executor. Unexported because callers always interact via runPipeline.
|
||||
type pipelineCtx struct {
|
||||
flags map[string]string
|
||||
stepOutputs []map[string]any
|
||||
}
|
||||
|
||||
// runPipeline executes route.Pipeline against runner, returning the last
|
||||
// "call"-type step's response (or a synthesized success payload if the
|
||||
// pipeline ends with a "download" step). The map is the shape returned to
|
||||
// the user via the standard output formatter.
|
||||
func runPipeline(
|
||||
ctx context.Context,
|
||||
cmd *cobra.Command,
|
||||
runner executor.Runner,
|
||||
route Route,
|
||||
flagValues map[string]string,
|
||||
) (map[string]any, error) {
|
||||
pctx := &pipelineCtx{
|
||||
flags: flagValues,
|
||||
stepOutputs: make([]map[string]any, 0, len(route.Pipeline)),
|
||||
}
|
||||
|
||||
var lastCallResponse map[string]any
|
||||
for i, step := range route.Pipeline {
|
||||
stepType := strings.TrimSpace(step.Type)
|
||||
if stepType == "" {
|
||||
stepType = "call"
|
||||
}
|
||||
switch stepType {
|
||||
case "call":
|
||||
resp, err := executePipelineCall(ctx, runner, route, step, pctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pipeline step %d (%s): %w", i, step.Tool, err)
|
||||
}
|
||||
pctx.stepOutputs = append(pctx.stepOutputs, resp)
|
||||
lastCallResponse = resp
|
||||
case "download":
|
||||
resp, err := executePipelineDownload(cmd, step, pctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pipeline step %d (download): %w", i, err)
|
||||
}
|
||||
pctx.stepOutputs = append(pctx.stepOutputs, resp)
|
||||
default:
|
||||
return nil, apperrors.NewValidation(
|
||||
fmt.Sprintf("pipeline step %d: unsupported type %q (allowed: call, download)", i, stepType),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if lastCallResponse != nil {
|
||||
return lastCallResponse, nil
|
||||
}
|
||||
return map[string]any{"success": true}, nil
|
||||
}
|
||||
|
||||
// executePipelineCall resolves args templates, then either polls or fires
|
||||
// a single MCP tool invocation via runner. PollUntilField + PollUntilValue
|
||||
// non-empty enable polling.
|
||||
func executePipelineCall(
|
||||
ctx context.Context,
|
||||
runner executor.Runner,
|
||||
route Route,
|
||||
step market.PipelineStep,
|
||||
pctx *pipelineCtx,
|
||||
) (map[string]any, error) {
|
||||
if strings.TrimSpace(step.Tool) == "" {
|
||||
return nil, apperrors.NewValidation("pipeline call step requires non-empty `tool`")
|
||||
}
|
||||
args, err := resolveArgs(step.Args, pctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
invoke := func() (map[string]any, error) {
|
||||
invocation := executor.NewCompatibilityInvocation(
|
||||
route.Use,
|
||||
route.Target.CanonicalProduct,
|
||||
step.Tool,
|
||||
args,
|
||||
)
|
||||
result, err := runner.Run(ctx, invocation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Response == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
// Fail-fast on MCP business errors. Pre-execution validation (cobra
|
||||
// MarkFlagRequired) only checks that the flag was set, not that
|
||||
// the value is non-empty — so a `--required-flag ""` reaches here
|
||||
// and the upstream tool rejects with errorCode. Without this check
|
||||
// the pipeline proceeds to poll/download and either spins until
|
||||
// PollTimeout or burns through retries.
|
||||
if errCode := getDotPath(result.Response, "content.errorCode"); errCode != nil && fmt.Sprint(errCode) != "" {
|
||||
msg := getDotPath(result.Response, "content.errorMessage")
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf(
|
||||
"%s rejected: %s — %v", step.Tool, errCode, msg,
|
||||
))
|
||||
}
|
||||
return result.Response, nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(step.PollUntilField) == "" {
|
||||
return invoke()
|
||||
}
|
||||
|
||||
// Polling loop.
|
||||
interval := time.Duration(step.PollIntervalSec) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 2 * time.Second
|
||||
}
|
||||
timeoutSec := step.PollTimeoutSec
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = 300
|
||||
}
|
||||
deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second)
|
||||
|
||||
for {
|
||||
resp, err := invoke()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actual := getDotPath(resp, step.PollUntilField)
|
||||
if actual != nil && fmt.Sprint(actual) == step.PollUntilValue {
|
||||
return resp, nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf(
|
||||
"pipeline poll timeout after %ds: field %q never reached value %q (last seen: %v)",
|
||||
timeoutSec, step.PollUntilField, step.PollUntilValue, actual,
|
||||
))
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(interval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executePipelineDownload resolves the URL template, fetches the body via
|
||||
// HTTP GET, and writes it to the path supplied by OutputFlag's user value.
|
||||
// Empty output path → print URL to stdout (terminal-friendly mode).
|
||||
func executePipelineDownload(
|
||||
cmd *cobra.Command,
|
||||
step market.PipelineStep,
|
||||
pctx *pipelineCtx,
|
||||
) (map[string]any, error) {
|
||||
urlAny, err := resolveTemplate(step.DownloadURLField, pctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
urlStr := strings.TrimSpace(fmt.Sprint(urlAny))
|
||||
if urlStr == "" {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf(
|
||||
"pipeline download: URL template %q resolved to empty value",
|
||||
step.DownloadURLField,
|
||||
))
|
||||
}
|
||||
|
||||
outputPath := strings.TrimSpace(pctx.flags[step.OutputFlag])
|
||||
jobID := fmt.Sprint(inferJobIDFromContext(pctx))
|
||||
|
||||
// Always print machine-parseable "key: value" lines. Tests and shell
|
||||
// pipelines that consume the pipeline output (regex / awk) rely on
|
||||
// this exact format. The structured JSON output follows via
|
||||
// output.WriteCommandPayload, so AI / SDK callers still get a typed
|
||||
// response.
|
||||
if jobID != "" {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "jobId: %s\n", jobID)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "downloadUrl: %s\n", urlStr)
|
||||
|
||||
if outputPath == "" {
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"downloadUrl": urlStr,
|
||||
"jobId": jobID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// If outputPath is a directory, infer filename from URL basename.
|
||||
if info, statErr := os.Stat(outputPath); statErr == nil && info.IsDir() {
|
||||
filename := inferFilenameFromURL(urlStr)
|
||||
if filename == "" {
|
||||
filename = fmt.Sprintf("export_%d", time.Now().Unix())
|
||||
}
|
||||
outputPath = filepath.Join(outputPath, filename)
|
||||
}
|
||||
|
||||
resp, err := http.Get(urlStr) //nolint:gosec // user-supplied URL via MCP discovery is expected
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP GET %s: %w", urlStr, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("HTTP GET %s: status %d", urlStr, resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create %s: %w", outputPath, err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
written, err := io.Copy(out, resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("write %s: %w", outputPath, err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "导出完成: %s (%d bytes)\n", outputPath, written)
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"downloadUrl": urlStr,
|
||||
"jobId": jobID,
|
||||
"output": outputPath,
|
||||
"size": written,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveArgs applies resolveTemplate to every value in the map.
|
||||
func resolveArgs(args map[string]string, pctx *pipelineCtx) (map[string]any, error) {
|
||||
out := make(map[string]any, len(args))
|
||||
for k, tmpl := range args {
|
||||
v, err := resolveTemplate(tmpl, pctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("arg %q: %w", k, err)
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveTemplate evaluates a single template string. Returns the literal
|
||||
// when input does not start with '$'.
|
||||
func resolveTemplate(tmpl string, pctx *pipelineCtx) (any, error) {
|
||||
s := strings.TrimSpace(tmpl)
|
||||
if !strings.HasPrefix(s, "$") {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Split on the first dot: head ("$flag" / "$step") + tail (rest).
|
||||
dot := strings.Index(s, ".")
|
||||
if dot <= 0 || dot == len(s)-1 {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("malformed template %q (expected $flag.<name> or $step.<idx>.<path>)", tmpl))
|
||||
}
|
||||
head := s[:dot]
|
||||
tail := s[dot+1:]
|
||||
|
||||
switch head {
|
||||
case "$flag":
|
||||
// tail is a flag alias name (no nested path supported)
|
||||
return pctx.flags[tail], nil
|
||||
case "$step":
|
||||
// tail format: <idx>.<dotPath>
|
||||
secondDot := strings.Index(tail, ".")
|
||||
if secondDot <= 0 || secondDot == len(tail)-1 {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("malformed $step template %q (expected $step.<idx>.<dotPath>)", tmpl))
|
||||
}
|
||||
idxStr := tail[:secondDot]
|
||||
dotPath := tail[secondDot+1:]
|
||||
idx, err := strconv.Atoi(idxStr)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("$step template %q has non-numeric index", tmpl))
|
||||
}
|
||||
if idx < 0 || idx >= len(pctx.stepOutputs) {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("$step template %q references step %d, but only %d step(s) executed so far", tmpl, idx, len(pctx.stepOutputs)))
|
||||
}
|
||||
return getDotPath(pctx.stepOutputs[idx], dotPath), nil
|
||||
default:
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("unknown template prefix %q in %q (allowed: $flag, $step)", head, tmpl))
|
||||
}
|
||||
}
|
||||
|
||||
// getDotPath walks dotPath through nested map[string]any. Returns nil if
|
||||
// any segment is missing or the value isn't a map at an intermediate step.
|
||||
func getDotPath(m map[string]any, dotPath string) any {
|
||||
parts := strings.Split(dotPath, ".")
|
||||
var current any = m
|
||||
for _, p := range parts {
|
||||
nested, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
current = nested[p]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// inferFilenameFromURL extracts the basename from a URL's path component,
|
||||
// stripping query string + fragment. Returns "" if the URL doesn't parse
|
||||
// or has no useful basename.
|
||||
func inferFilenameFromURL(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
base := path.Base(u.Path)
|
||||
if base == "" || base == "/" || base == "." {
|
||||
return ""
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// inferJobIDFromContext walks prior step outputs looking for a `jobId`
|
||||
// field at top level or one level under common MCP wrappers ("content" /
|
||||
// "result"), so the synthetic download response can echo it back to the
|
||||
// user. Returns "" when no jobId is present anywhere in prior responses.
|
||||
func inferJobIDFromContext(pctx *pipelineCtx) any {
|
||||
candidates := []string{"jobId", "content.jobId", "result.jobId"}
|
||||
for i := len(pctx.stepOutputs) - 1; i >= 0; i-- {
|
||||
for _, p := range candidates {
|
||||
if v := getDotPath(pctx.stepOutputs[i], p); v != nil && fmt.Sprint(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractFlagValuesByAlias reads the cobra command's flag values keyed by
|
||||
// the FlagBinding's primary CLI flag name, so the pipeline executor can
|
||||
// resolve "$flag.<name>" templates in O(1). Pipeline-local flags are
|
||||
// always included (they are the whole point of the lookup).
|
||||
//
|
||||
// Note on key choice: buildOverrideBindings populates FlagName from the
|
||||
// envelope's `alias` field (or kebab-case of the MCP property name when
|
||||
// alias is empty), and leaves the FlagBinding.Alias struct field empty —
|
||||
// so $flag templates reference the user-visible CLI flag name, e.g.
|
||||
// "$flag.node" matches `--node`.
|
||||
func extractFlagValuesByAlias(cmd *cobra.Command, bindings []FlagBinding) map[string]string {
|
||||
flags := cmd.Flags()
|
||||
out := make(map[string]string, len(bindings))
|
||||
for _, b := range bindings {
|
||||
primary := strings.TrimSpace(b.FlagName)
|
||||
if primary == "" {
|
||||
primary = strings.TrimSpace(b.Alias)
|
||||
}
|
||||
if primary == "" {
|
||||
continue
|
||||
}
|
||||
// Try the primary flag name first, then any of the extra aliases.
|
||||
// Whichever the user actually set wins; if none was set, the
|
||||
// cobra-level default value is returned.
|
||||
candidates := make([]string, 0, 2+len(b.Aliases))
|
||||
candidates = append(candidates, primary)
|
||||
if a := strings.TrimSpace(b.Alias); a != "" && a != primary {
|
||||
candidates = append(candidates, a)
|
||||
}
|
||||
for _, a := range b.Aliases {
|
||||
if a = strings.TrimSpace(a); a != "" {
|
||||
candidates = append(candidates, a)
|
||||
}
|
||||
}
|
||||
var value string
|
||||
for _, c := range candidates {
|
||||
f := flags.Lookup(c)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
value = f.Value.String()
|
||||
if f.Changed {
|
||||
break
|
||||
}
|
||||
}
|
||||
out[primary] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// jsonRoundTrip marshals + unmarshals so user-provided strings come out
|
||||
// the other side as Go primitives where appropriate. Unused for now —
|
||||
// the resolveTemplate path returns strings as-is to keep the contract
|
||||
// simple; tools that need JSON-shaped values can use the existing
|
||||
// `transform: "json_parse_strict"` on the relevant flag (post-pipeline
|
||||
// composition is not in scope for the MVP).
|
||||
var _ = json.Unmarshal
|
||||
+504
-51
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
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/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/convert"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -56,25 +57,54 @@ type Target struct {
|
||||
type FlagBinding struct {
|
||||
FlagName string
|
||||
Alias string
|
||||
Short string
|
||||
Property string
|
||||
Kind ValueKind
|
||||
Usage string
|
||||
Required bool
|
||||
// Aliases are additional hidden flag names that map to the same MCP
|
||||
// parameter. Any of them being set satisfies Required, and the value
|
||||
// is resolved via firstChangedFlag(FlagName, Alias, Aliases...).
|
||||
// Mirrors cmdutil.ValidateRequiredFlagWithAliases / FlagOrFallback.
|
||||
Aliases []string
|
||||
// PipelineLocal, when true, marks this binding as CLI-side only — its
|
||||
// value is consumed by the pipeline executor (e.g. as an HTTP
|
||||
// download destination) and NOT forwarded to any MCP tool's params.
|
||||
PipelineLocal bool
|
||||
Short string
|
||||
Property string
|
||||
Kind ValueKind
|
||||
Usage string
|
||||
Required bool
|
||||
// Default is the cobra-level flag default value as a string. Parsed
|
||||
// into the Kind-appropriate primitive at registration time. Empty
|
||||
// string keeps the existing zero-value default. This only affects
|
||||
// what cobra renders in --help (the "(default ...)" suffix); it does
|
||||
// NOT inject the value into MCP params on its own — CollectBindings
|
||||
// still gates writes by user-changed flags via firstChangedFlag.
|
||||
Default string
|
||||
// Positional binds this parameter to a positional CLI argument rather
|
||||
// than a --flag. PositionalIndex is the 0-based slot.
|
||||
Positional bool
|
||||
PositionalIndex int
|
||||
}
|
||||
|
||||
type Normalizer func(cmd *cobra.Command, params map[string]any) error
|
||||
|
||||
type Route struct {
|
||||
Use string
|
||||
Aliases []string
|
||||
Short string
|
||||
Long string
|
||||
Example string
|
||||
Hidden bool
|
||||
Target Target
|
||||
Bindings []FlagBinding
|
||||
Use string
|
||||
Aliases []string
|
||||
Short string
|
||||
Long string
|
||||
Example string
|
||||
Hidden bool
|
||||
Target Target
|
||||
Bindings []FlagBinding
|
||||
// Pipeline, when non-empty, replaces the single-tool dispatch with a
|
||||
// multi-step orchestration. NewDirectCommand sees this and wires the
|
||||
// pipeline executor into RunE instead of the standard
|
||||
// invoke-then-output flow. See internal/compat/pipeline.go.
|
||||
Pipeline []market.PipelineStep
|
||||
Normalizer Normalizer
|
||||
// OutputTransform, when non-nil, post-processes the MCP response payload
|
||||
// (rename / drop / columns) before the formatter emits it. Wired up from
|
||||
// CLIToolOverride.OutputFormat. See discovery-schema-v3 §2.5.
|
||||
OutputTransform func(map[string]any) map[string]any
|
||||
}
|
||||
|
||||
type CommandFactory func(runner executor.Runner) *cobra.Command
|
||||
@@ -109,14 +139,87 @@ func NewFallbackCommands(runner executor.Runner) []*cobra.Command {
|
||||
var NewGroupCommand = cobracmd.NewGroupCommand
|
||||
|
||||
func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
|
||||
// Compute positional arity. Two counts:
|
||||
// - totalMax: the highest PositionalIndex+1 across all positional bindings
|
||||
// (caps how many trailing args cobra accepts).
|
||||
// - strictMin: the highest PositionalIndex+1 among "pure" positional
|
||||
// bindings (no flag aliases). Backward-compat: any pure positional
|
||||
// binding implies required arity at parse time, regardless of Required.
|
||||
//
|
||||
// Dual-mode positional bindings (positional + envelope-declared flag
|
||||
// aliases, e.g. `{positional:true, alias:"query", aliases:["keyword"]}`)
|
||||
// are counted into totalMax but excluded from strictMin so a flag-only
|
||||
// invocation parses; their required-presence is enforced by
|
||||
// validateRequiredPositionalBindings inside RunE.
|
||||
//
|
||||
// For positional bindings, buildOverrideBindings populates FlagName /
|
||||
// Aliases only when the envelope explicitly declared them, so the
|
||||
// dual-mode detection here is unambiguous.
|
||||
strictMin := 0
|
||||
totalMax := 0
|
||||
for _, b := range route.Bindings {
|
||||
if !b.Positional {
|
||||
continue
|
||||
}
|
||||
if b.PositionalIndex+1 > totalMax {
|
||||
totalMax = b.PositionalIndex + 1
|
||||
}
|
||||
hasFlagAlias := strings.TrimSpace(b.Alias) != "" || strings.TrimSpace(b.FlagName) != "" || len(b.Aliases) > 0
|
||||
if !hasFlagAlias && b.PositionalIndex+1 > strictMin {
|
||||
strictMin = b.PositionalIndex + 1
|
||||
}
|
||||
}
|
||||
var argsValidator cobra.PositionalArgs = cobra.ArbitraryArgs
|
||||
switch {
|
||||
case totalMax == 0:
|
||||
argsValidator = cobra.ArbitraryArgs
|
||||
case strictMin > 0 && strictMin == totalMax:
|
||||
argsValidator = cobra.MinimumNArgs(strictMin)
|
||||
case strictMin > 0:
|
||||
argsValidator = cobra.RangeArgs(strictMin, totalMax)
|
||||
default:
|
||||
argsValidator = cobra.MaximumNArgs(totalMax)
|
||||
}
|
||||
|
||||
// Extend Use with [<placeholder>] tokens for positional bindings so
|
||||
// `--help` renders `cmd [arg1] [arg2] [flags]`, matching hardcoded
|
||||
// helper commands' style (e.g. devdoc article search [keyword]).
|
||||
use := route.Use
|
||||
if totalMax > 0 {
|
||||
ordered := make([]FlagBinding, 0, totalMax)
|
||||
for _, b := range route.Bindings {
|
||||
if b.Positional {
|
||||
ordered = append(ordered, b)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
return ordered[i].PositionalIndex < ordered[j].PositionalIndex
|
||||
})
|
||||
var sb strings.Builder
|
||||
sb.WriteString(use)
|
||||
for _, b := range ordered {
|
||||
name := strings.TrimSpace(b.Property)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(b.FlagName)
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(" [")
|
||||
sb.WriteString(name)
|
||||
sb.WriteString("]")
|
||||
}
|
||||
use = sb.String()
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: route.Use,
|
||||
Use: use,
|
||||
Aliases: append([]string(nil), route.Aliases...),
|
||||
Short: route.Short,
|
||||
Long: route.Long,
|
||||
Example: route.Example,
|
||||
Hidden: route.Hidden,
|
||||
Args: cobra.NoArgs,
|
||||
Args: argsValidator,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
jsonPayload, err := cmd.Flags().GetString("json")
|
||||
@@ -141,10 +244,27 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
|
||||
params[key] = value
|
||||
}
|
||||
|
||||
// Inject positional args into params according to each binding's
|
||||
// PositionalIndex. Pure positional bindings are not registered as
|
||||
// flags; dual-mode positional bindings (positional + alias) only
|
||||
// fall through to positional injection when their flag aliases
|
||||
// were not used (collectPositionalBindings skips when params
|
||||
// already contains the property).
|
||||
if err := collectPositionalBindings(args, route.Bindings, params); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Collect schema-derived flags (from buildFlagsFromDetailSchema)
|
||||
// that are not covered by explicit bindings.
|
||||
collectSchemaFlags(cmd, route.Bindings, params)
|
||||
|
||||
// Required-presence check for positional bindings — must run after
|
||||
// both flag (CollectBindings) and positional (collectPositionalBindings)
|
||||
// have had a chance to populate params.
|
||||
if err := validateRequiredPositionalBindings(cmd, route.Bindings, params); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if route.Normalizer != nil {
|
||||
if err := route.Normalizer(cmd, params); err != nil {
|
||||
return err
|
||||
@@ -167,6 +287,33 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
|
||||
delete(params, "_blocked")
|
||||
}
|
||||
|
||||
// §pipeline: when the override declares a multi-step pipeline,
|
||||
// dispatch via the pipeline executor instead of the single-tool
|
||||
// invoke-then-output flow. The executor reads flag values by
|
||||
// alias (so $flag.<alias> templates resolve), walks each step,
|
||||
// handles polling + downloads, and returns the last "call"
|
||||
// step's response as the payload to the formatter.
|
||||
if len(route.Pipeline) > 0 {
|
||||
flagValues := extractFlagValuesByAlias(cmd, route.Bindings)
|
||||
resp, err := runPipeline(cmd.Context(), cmd, runner, route, flagValues)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := executor.Result{
|
||||
Invocation: executor.NewCompatibilityInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
route.Target.CanonicalProduct,
|
||||
"pipeline",
|
||||
params,
|
||||
),
|
||||
Response: resp,
|
||||
}
|
||||
if route.OutputTransform != nil && result.Response != nil {
|
||||
result.Response = route.OutputTransform(result.Response)
|
||||
}
|
||||
return output.WriteCommandPayload(cmd, result, output.FormatJSON)
|
||||
}
|
||||
|
||||
invocation := executor.NewCompatibilityInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
route.Target.CanonicalProduct,
|
||||
@@ -180,6 +327,9 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if route.OutputTransform != nil && result.Response != nil {
|
||||
result.Response = route.OutputTransform(result.Response)
|
||||
}
|
||||
return output.WriteCommandPayload(cmd, result, output.FormatJSON)
|
||||
},
|
||||
}
|
||||
@@ -196,8 +346,64 @@ func NewCuratedCommand(route Route, runner executor.Runner) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// parseFlagDefault converts a string-form envelope default into the typed
|
||||
// primitives used by pflag's *P helpers. Unparseable values silently fall
|
||||
// back to the type's zero value so a malformed envelope downgrades to
|
||||
// "no default in --help" rather than a panic at startup. The slice form
|
||||
// splits on commas and trims whitespace, mirroring pflag.StringSlice
|
||||
// behavior; empty/whitespace-only segments are dropped.
|
||||
func parseFlagDefault(kind ValueKind, raw string) (defStr string, defInt int, defFloat float64, defBool bool, defSlice []string) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
switch kind {
|
||||
case ValueString, ValueJSON:
|
||||
// Preserve raw (not trimmed) so explicitly-padded defaults survive.
|
||||
defStr = raw
|
||||
case ValueInt:
|
||||
if trimmed != "" {
|
||||
if v, err := strconv.Atoi(trimmed); err == nil {
|
||||
defInt = v
|
||||
}
|
||||
}
|
||||
case ValueFloat:
|
||||
if trimmed != "" {
|
||||
if v, err := strconv.ParseFloat(trimmed, 64); err == nil {
|
||||
defFloat = v
|
||||
}
|
||||
}
|
||||
case ValueBool:
|
||||
if trimmed != "" {
|
||||
if v, err := strconv.ParseBool(trimmed); err == nil {
|
||||
defBool = v
|
||||
}
|
||||
}
|
||||
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
|
||||
if trimmed != "" {
|
||||
for _, p := range strings.Split(trimmed, ",") {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
defSlice = append(defSlice, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
|
||||
for _, binding := range bindings {
|
||||
// Positional bindings are collected from cobra args rather than flags.
|
||||
// Exception: dual-mode bindings (positional + envelope-declared flag
|
||||
// aliases) also register the aliases so users can pass either
|
||||
// `cmd VALUE` or `cmd --primary VALUE`. Required-presence is enforced
|
||||
// later by validateRequiredPositionalBindings instead of MarkFlagRequired.
|
||||
if binding.Positional {
|
||||
primary := strings.TrimSpace(binding.FlagName)
|
||||
alias := strings.TrimSpace(binding.Alias)
|
||||
if primary == "" && alias == "" && len(binding.Aliases) == 0 {
|
||||
continue
|
||||
}
|
||||
registerPositionalAliasFlags(cmd, binding)
|
||||
continue
|
||||
}
|
||||
primary := strings.TrimSpace(binding.FlagName)
|
||||
if primary == "" {
|
||||
continue
|
||||
@@ -206,43 +412,77 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
|
||||
if alias == primary {
|
||||
alias = ""
|
||||
}
|
||||
// Dedupe extra aliases against primary + single alias and each other.
|
||||
var extras []string
|
||||
if len(binding.Aliases) > 0 {
|
||||
seen := map[string]bool{primary: true, "json": true, "params": true}
|
||||
if alias != "" {
|
||||
seen[alias] = true
|
||||
}
|
||||
extras = make([]string, 0, len(binding.Aliases))
|
||||
for _, a := range binding.Aliases {
|
||||
a = strings.TrimSpace(a)
|
||||
if a == "" || seen[a] {
|
||||
continue
|
||||
}
|
||||
seen[a] = true
|
||||
extras = append(extras, a)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse binding.Default once per binding into Kind-typed values used
|
||||
// by both the primary and hidden-alias registrations below. Hidden
|
||||
// aliases share the same default so users typing the legacy alias
|
||||
// see consistent --help text and zero-value behavior.
|
||||
defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default)
|
||||
|
||||
registerHidden := func(name string, suffix string) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
switch binding.Kind {
|
||||
case ValueString:
|
||||
cmd.Flags().String(name, defStr, binding.Usage+suffix)
|
||||
case ValueInt:
|
||||
cmd.Flags().Int(name, defInt, binding.Usage+suffix)
|
||||
case ValueFloat:
|
||||
cmd.Flags().Float64(name, defFloat, binding.Usage+suffix)
|
||||
case ValueBool:
|
||||
cmd.Flags().Bool(name, defBool, binding.Usage+suffix)
|
||||
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
|
||||
cmd.Flags().StringSlice(name, defSlice, binding.Usage+suffix)
|
||||
case ValueJSON:
|
||||
cmd.Flags().String(name, defStr, binding.Usage+suffix)
|
||||
}
|
||||
_ = cmd.Flags().MarkHidden(name)
|
||||
}
|
||||
|
||||
switch binding.Kind {
|
||||
case ValueString:
|
||||
cmd.Flags().StringP(primary, binding.Short, "", binding.Usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().String(alias, "", binding.Usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
cmd.Flags().StringP(primary, binding.Short, defStr, binding.Usage)
|
||||
case ValueInt:
|
||||
cmd.Flags().IntP(primary, binding.Short, 0, binding.Usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().Int(alias, 0, binding.Usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
cmd.Flags().IntP(primary, binding.Short, defInt, binding.Usage)
|
||||
case ValueFloat:
|
||||
cmd.Flags().Float64P(primary, binding.Short, 0, binding.Usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().Float64(alias, 0, binding.Usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
cmd.Flags().Float64P(primary, binding.Short, defFloat, binding.Usage)
|
||||
case ValueBool:
|
||||
cmd.Flags().BoolP(primary, binding.Short, false, binding.Usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().Bool(alias, false, binding.Usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
cmd.Flags().BoolP(primary, binding.Short, defBool, binding.Usage)
|
||||
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
|
||||
cmd.Flags().StringSliceP(primary, binding.Short, nil, binding.Usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().StringSlice(alias, nil, binding.Usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
cmd.Flags().StringSliceP(primary, binding.Short, defSlice, binding.Usage)
|
||||
case ValueJSON:
|
||||
cmd.Flags().StringP(primary, binding.Short, "", binding.Usage+" (JSON)")
|
||||
if alias != "" {
|
||||
cmd.Flags().String(alias, "", binding.Usage+" (alias, JSON)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
cmd.Flags().StringP(primary, binding.Short, defStr, binding.Usage+" (JSON)")
|
||||
}
|
||||
registerHidden(alias, " (alias)")
|
||||
for _, extra := range extras {
|
||||
registerHidden(extra, " (alias)")
|
||||
}
|
||||
if binding.Required {
|
||||
// When no hidden aliases exist, lean on cobra's native required
|
||||
// validation for the best UX (colored error, shown in --help).
|
||||
// When aliases exist, CollectBindings does its own "any-of-these
|
||||
// is set" check so users who type the hidden alias do not hit
|
||||
// cobra yelling about the primary being missing.
|
||||
if alias == "" && len(extras) == 0 {
|
||||
_ = cmd.MarkFlagRequired(primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,6 +492,165 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
|
||||
_ = cmd.Flags().MarkHidden("params")
|
||||
}
|
||||
|
||||
// registerPositionalAliasFlags registers the visible primary flag and any
|
||||
// hidden aliases for a "dual-mode" positional binding (envelope:
|
||||
// `{positional:true, alias:"X", aliases:["Y"]}`). Required-presence is
|
||||
// intentionally deferred to validateRequiredPositionalBindings — cobra's
|
||||
// MarkFlagRequired would yell even when the user supplied the value as a
|
||||
// positional arg.
|
||||
func registerPositionalAliasFlags(cmd *cobra.Command, binding FlagBinding) {
|
||||
primary := strings.TrimSpace(binding.FlagName)
|
||||
alias := strings.TrimSpace(binding.Alias)
|
||||
if alias == primary {
|
||||
alias = ""
|
||||
}
|
||||
|
||||
// Dedupe extras against primary + alias and reserved internal names.
|
||||
seen := map[string]bool{"json": true, "params": true}
|
||||
if primary != "" {
|
||||
seen[primary] = true
|
||||
}
|
||||
if alias != "" {
|
||||
seen[alias] = true
|
||||
}
|
||||
extras := make([]string, 0, len(binding.Aliases))
|
||||
for _, a := range binding.Aliases {
|
||||
a = strings.TrimSpace(a)
|
||||
if a == "" || seen[a] {
|
||||
continue
|
||||
}
|
||||
seen[a] = true
|
||||
extras = append(extras, a)
|
||||
}
|
||||
|
||||
defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default)
|
||||
|
||||
register := func(name string, withShort bool, hidden bool, usageSuffix string) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
short := ""
|
||||
if withShort {
|
||||
short = binding.Short
|
||||
}
|
||||
usage := binding.Usage + usageSuffix
|
||||
switch binding.Kind {
|
||||
case ValueString:
|
||||
cmd.Flags().StringP(name, short, defStr, usage)
|
||||
case ValueInt:
|
||||
cmd.Flags().IntP(name, short, defInt, usage)
|
||||
case ValueFloat:
|
||||
cmd.Flags().Float64P(name, short, defFloat, usage)
|
||||
case ValueBool:
|
||||
cmd.Flags().BoolP(name, short, defBool, usage)
|
||||
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
|
||||
cmd.Flags().StringSliceP(name, short, defSlice, usage)
|
||||
case ValueJSON:
|
||||
cmd.Flags().StringP(name, short, defStr, usage+" (JSON)")
|
||||
default:
|
||||
cmd.Flags().StringP(name, short, defStr, usage)
|
||||
}
|
||||
if hidden {
|
||||
_ = cmd.Flags().MarkHidden(name)
|
||||
}
|
||||
}
|
||||
|
||||
register(primary, true, false, "")
|
||||
register(alias, false, true, " (alias)")
|
||||
for _, e := range extras {
|
||||
register(e, false, true, " (alias)")
|
||||
}
|
||||
}
|
||||
|
||||
// collectPositionalBindings pulls positional args according to the bindings
|
||||
// and injects them into params[property]. Missing slots are skipped (cobra
|
||||
// arity validation already ran before RunE).
|
||||
func collectPositionalBindings(args []string, bindings []FlagBinding, params map[string]any) error {
|
||||
for _, binding := range bindings {
|
||||
if !binding.Positional {
|
||||
continue
|
||||
}
|
||||
property := strings.TrimSpace(binding.Property)
|
||||
if property == "" {
|
||||
continue
|
||||
}
|
||||
// Dual-mode positional: if the user already provided the value via a
|
||||
// flag alias (CollectBindings wrote it), honor flag > positional.
|
||||
if _, ok := params[property]; ok {
|
||||
continue
|
||||
}
|
||||
if binding.PositionalIndex < 0 || binding.PositionalIndex >= len(args) {
|
||||
continue
|
||||
}
|
||||
raw := args[binding.PositionalIndex]
|
||||
switch binding.Kind {
|
||||
case ValueInt:
|
||||
v, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("positional argument %d (%s) must be int", binding.PositionalIndex, property))
|
||||
}
|
||||
params[property] = v
|
||||
case ValueFloat:
|
||||
v, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("positional argument %d (%s) must be float", binding.PositionalIndex, property))
|
||||
}
|
||||
params[property] = v
|
||||
case ValueBool:
|
||||
v, err := strconv.ParseBool(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("positional argument %d (%s) must be bool", binding.PositionalIndex, property))
|
||||
}
|
||||
params[property] = v
|
||||
default:
|
||||
params[property] = raw
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRequiredPositionalBindings enforces required-presence for positional
|
||||
// bindings whose original envelope spec set required=true. The arity validator
|
||||
// for dual-mode positionals is intentionally relaxed (MaximumNArgs / RangeArgs
|
||||
// excluding the dual slot) so a flag-only invocation is permitted; this check
|
||||
// closes the loop by rejecting the case where neither the positional arg nor
|
||||
// any flag alias was supplied.
|
||||
func validateRequiredPositionalBindings(cmd *cobra.Command, bindings []FlagBinding, params map[string]any) error {
|
||||
for _, binding := range bindings {
|
||||
if !binding.Positional || !binding.Required {
|
||||
continue
|
||||
}
|
||||
property := strings.TrimSpace(binding.Property)
|
||||
if property == "" {
|
||||
continue
|
||||
}
|
||||
if v, ok := params[property]; ok {
|
||||
if s, isStr := v.(string); !isStr || strings.TrimSpace(s) != "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Compose candidate flag names so the error message points users at
|
||||
// the first writable label even for flag-only invocations.
|
||||
primary := strings.TrimSpace(binding.FlagName)
|
||||
alias := strings.TrimSpace(binding.Alias)
|
||||
if _, changed := firstChangedFlag(cmd, append([]string{primary, alias}, binding.Aliases...)...); changed {
|
||||
continue
|
||||
}
|
||||
display := primary
|
||||
if display == "" {
|
||||
display = alias
|
||||
}
|
||||
if display == "" && len(binding.Aliases) > 0 {
|
||||
display = binding.Aliases[0]
|
||||
}
|
||||
if display == "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("positional argument <%s> is required", property))
|
||||
}
|
||||
return apperrors.NewValidation(fmt.Sprintf("--%s (or positional <%s>) is required", display, property))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectSchemaFlags picks up flags created by buildFlagsFromDetailSchema that
|
||||
// have no explicit FlagBinding. This bridges the gap for plugin-defined tools
|
||||
// whose parameters come from the MCP inputSchema rather than CLIToolOverride.Flags.
|
||||
@@ -265,6 +664,11 @@ func collectSchemaFlags(cmd *cobra.Command, bindings []FlagBinding, params map[s
|
||||
if a := strings.TrimSpace(b.Alias); a != "" {
|
||||
bound[a] = true
|
||||
}
|
||||
for _, extra := range b.Aliases {
|
||||
if e := strings.TrimSpace(extra); e != "" {
|
||||
bound[e] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reserved/internal flags that should never be forwarded as tool params.
|
||||
@@ -316,36 +720,85 @@ func toOriginalParamName(flagName string) string {
|
||||
return strings.ReplaceAll(flagName, "-", "_")
|
||||
}
|
||||
|
||||
// firstChangedFlag returns the first name (in order) whose cobra flag has
|
||||
// been set by the user. Whitespace-only or empty entries are skipped.
|
||||
// Mirrors wukong cmdutil.FlagOrFallback precedence: primary > alias >
|
||||
// extraAliases in declaration order.
|
||||
func firstChangedFlag(cmd *cobra.Command, names ...string) (name string, changed bool) {
|
||||
for _, n := range names {
|
||||
n = strings.TrimSpace(n)
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if cobracmd.FlagChanged(cmd, n) {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func CollectBindings(cmd *cobra.Command, bindings []FlagBinding, existing map[string]any) (map[string]any, error) {
|
||||
if existing == nil {
|
||||
existing = map[string]any{}
|
||||
}
|
||||
params := make(map[string]any)
|
||||
for _, binding := range bindings {
|
||||
// Pipeline-local flags exist purely for the pipeline executor
|
||||
// (e.g. --output destination paths) and must never be forwarded
|
||||
// to MCP tools as params, otherwise the upstream API would
|
||||
// either reject the unknown field or silently store junk.
|
||||
if binding.PipelineLocal {
|
||||
continue
|
||||
}
|
||||
if binding.Positional {
|
||||
// Pure positional (no flag aliases) is handled by
|
||||
// collectPositionalBindings. Dual-mode positional bindings
|
||||
// (envelope: positional + alias/aliases) fall through so any
|
||||
// user-supplied flag value wins over the positional arg.
|
||||
primary := strings.TrimSpace(binding.FlagName)
|
||||
alias := strings.TrimSpace(binding.Alias)
|
||||
if primary == "" && alias == "" && len(binding.Aliases) == 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
primaryName := strings.TrimSpace(binding.FlagName)
|
||||
if primaryName == "" {
|
||||
continue
|
||||
}
|
||||
aliasName := strings.TrimSpace(binding.Alias)
|
||||
primaryChanged := cobracmd.FlagChanged(cmd, primaryName)
|
||||
aliasChanged := aliasName != "" && cobracmd.FlagChanged(cmd, aliasName)
|
||||
|
||||
flagName := primaryName
|
||||
if aliasChanged {
|
||||
flagName = aliasName
|
||||
// Candidate flag names in precedence order: primary, single alias,
|
||||
// then extra aliases. Whichever is set first wins; mirrors the
|
||||
// semantics of cmdutil.FlagOrFallback.
|
||||
candidates := make([]string, 0, 2+len(binding.Aliases))
|
||||
candidates = append(candidates, primaryName)
|
||||
if aliasName != "" && aliasName != primaryName {
|
||||
candidates = append(candidates, aliasName)
|
||||
}
|
||||
for _, extra := range binding.Aliases {
|
||||
e := strings.TrimSpace(extra)
|
||||
if e == "" || e == primaryName || e == aliasName {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, e)
|
||||
}
|
||||
|
||||
flagName, anyChanged := firstChangedFlag(cmd, candidates...)
|
||||
if !anyChanged {
|
||||
flagName = primaryName
|
||||
}
|
||||
|
||||
flag := cmd.Flags().Lookup(flagName)
|
||||
if flag == nil {
|
||||
continue
|
||||
}
|
||||
if binding.Required && !primaryChanged && !aliasChanged {
|
||||
if binding.Required && !anyChanged && !binding.Positional {
|
||||
if _, ok := existing[binding.Property]; ok {
|
||||
continue
|
||||
}
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--%s is required", primaryName))
|
||||
}
|
||||
if !primaryChanged && !aliasChanged {
|
||||
if !anyChanged {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,71 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestApplyBindings_PositionalWithFlagAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// envelope shape: { keyword: { alias: "query", aliases: ["keyword"], positional: true } }
|
||||
bindings := []FlagBinding{
|
||||
{
|
||||
FlagName: "query",
|
||||
Aliases: []string{"keyword"},
|
||||
Property: "keyword",
|
||||
Kind: ValueString,
|
||||
Usage: "搜索关键词",
|
||||
Required: true,
|
||||
Positional: true,
|
||||
PositionalIndex: 0,
|
||||
},
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "search"}
|
||||
ApplyBindings(cmd, bindings)
|
||||
|
||||
primary := cmd.Flags().Lookup("query")
|
||||
if primary == nil {
|
||||
t.Fatal("--query flag should be registered for dual-mode positional")
|
||||
}
|
||||
if primary.Hidden {
|
||||
t.Fatal("--query flag should be visible")
|
||||
}
|
||||
hidden := cmd.Flags().Lookup("keyword")
|
||||
if hidden == nil {
|
||||
t.Fatal("--keyword alias flag should be registered")
|
||||
}
|
||||
if !hidden.Hidden {
|
||||
t.Fatal("--keyword alias flag should be hidden")
|
||||
}
|
||||
|
||||
// --query should NOT be marked required at cobra level — that would
|
||||
// break flag-only invocation when arity is relaxed.
|
||||
if _, ok := primary.Annotations[cobra.BashCompOneRequiredFlag]; ok {
|
||||
t.Fatal("--query should not be MarkFlagRequired (validation happens in RunE)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBindings_PurePositionalSkipsFlagRegistration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Pure positional (no Alias / no Aliases) → no flag should be registered;
|
||||
// arity validator (set in NewDirectCommand) handles required-presence.
|
||||
bindings := []FlagBinding{
|
||||
{
|
||||
Property: "text",
|
||||
Kind: ValueString,
|
||||
Required: true,
|
||||
Positional: true,
|
||||
PositionalIndex: 0,
|
||||
},
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "send"}
|
||||
ApplyBindings(cmd, bindings)
|
||||
|
||||
if f := cmd.Flags().Lookup("text"); f != nil {
|
||||
t.Fatalf("pure positional should not register a flag, got %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectBindingsParsesTypedValuesAndAcceptsAliasFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -16,9 +16,12 @@ package compat
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
@@ -26,7 +29,8 @@ import (
|
||||
)
|
||||
|
||||
// ApplyTransform applies a named transform rule to a value.
|
||||
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse, enum_map.
|
||||
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse,
|
||||
// json_parse_strict, enum_map, file_read, invert_bool.
|
||||
func ApplyTransform(value any, transform string, args map[string]any) (any, error) {
|
||||
switch strings.TrimSpace(transform) {
|
||||
case "":
|
||||
@@ -37,8 +41,37 @@ func ApplyTransform(value any, transform string, args map[string]any) (any, erro
|
||||
return transformCSVToArray(value)
|
||||
case "json_parse":
|
||||
return transformJSONParse(value)
|
||||
case "json_parse_strict":
|
||||
return transformJSONParseStrict(value)
|
||||
case "enum_map":
|
||||
return transformEnumMap(value, args)
|
||||
case "file_read":
|
||||
return transformFileRead(value)
|
||||
case "invert_bool":
|
||||
return transformInvertBool(value)
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
// transformInvertBool flips a boolean: true → false, false → true. Strings
|
||||
// "true"/"false" (any case) are accepted. Used by envelope flags whose CLI
|
||||
// surface and MCP body have opposite semantics — e.g. `--off` (CLI) maps to
|
||||
// `mute=true` (MCP) for "mute is enabled", so the flag override declares
|
||||
// `transform: invert_bool` and the framework flips at send time.
|
||||
func transformInvertBool(value any) (any, error) {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return !v, nil
|
||||
case string:
|
||||
s := strings.ToLower(strings.TrimSpace(v))
|
||||
switch s {
|
||||
case "true", "1", "yes", "on":
|
||||
return false, nil
|
||||
case "false", "0", "no", "off", "":
|
||||
return true, nil
|
||||
}
|
||||
return value, nil
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
@@ -151,6 +184,30 @@ func transformJSONParse(value any) (any, error) {
|
||||
)
|
||||
}
|
||||
|
||||
// transformJSONParseStrict is the strict variant of json_parse: only accepts
|
||||
// well-formed JSON, rejecting input that the YAML fallback would otherwise
|
||||
// silently coerce to a scalar string. Use when the upstream tool requires a
|
||||
// structured array/object value and "garbage in → empty out" is unacceptable.
|
||||
func transformJSONParseStrict(value any) (any, error) {
|
||||
s, ok := toString(value)
|
||||
if !ok {
|
||||
return value, nil
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return value, nil
|
||||
}
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(s), &parsed); err != nil {
|
||||
return nil, apperrors.NewValidation(
|
||||
"json_parse_strict: input is not valid JSON; " +
|
||||
"this transform rejects YAML-style ad-hoc input — quote the whole value " +
|
||||
"as strict JSON (e.g. '[{\"key\":\"value\"}]') or use `json_parse` for YAML-tolerant parsing",
|
||||
)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func transformEnumMap(value any, args map[string]any) (any, error) {
|
||||
s, ok := toString(value)
|
||||
if !ok {
|
||||
@@ -167,6 +224,44 @@ func transformEnumMap(value any, args map[string]any) (any, error) {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// transformFileRead reads the file at the given path and returns its contents
|
||||
// as a UTF-8 string. The special path "-" reads from stdin.
|
||||
//
|
||||
// Typical envelope use is paired with CLIFlagOverride.MapsTo so a path-typed
|
||||
// CLI flag (e.g. --content-file ./a.md) routes the file contents into a
|
||||
// content-typed MCP parameter (e.g. markdown), letting a sibling literal
|
||||
// flag (--content "# 标题") feed the same parameter without conflict.
|
||||
//
|
||||
// Errors are surfaced as validation errors so the dispatcher returns exit code 2
|
||||
// (user input) rather than the generic exit code 1 (transient failure).
|
||||
func transformFileRead(value any) (any, error) {
|
||||
s, ok := toString(value)
|
||||
if !ok {
|
||||
return nil, apperrors.NewValidation("file_read: expected string path, got non-string value")
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, apperrors.NewValidation("file_read: empty path")
|
||||
}
|
||||
var buf []byte
|
||||
var err error
|
||||
if s == "-" {
|
||||
buf, err = io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("file_read: read stdin: %v", err))
|
||||
}
|
||||
} else {
|
||||
buf, err = os.ReadFile(s)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("file_read: read %q: %v", s, err))
|
||||
}
|
||||
}
|
||||
if !utf8.Valid(buf) {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("file_read: %q is not valid UTF-8", s))
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
func toString(v any) (string, bool) {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
package compat
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -123,3 +126,162 @@ func TestJSONParse_InvalidInput(t *testing.T) {
|
||||
t.Fatal("error message should be non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_BasicFile exercises the happy path: a UTF-8 file on disk is
|
||||
// read in full and surfaced as a string value. This is the contract the
|
||||
// `--content-file ./a.md` flag relies on so the upstream MCP tool sees the
|
||||
// file contents in place of the path.
|
||||
func TestFileRead_BasicFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.md")
|
||||
contents := "# Heading\n\n- bullet one\n- bullet two\n"
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
got, err := ApplyTransform(path, "file_read", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("file_read should succeed, got err: %v", err)
|
||||
}
|
||||
if got != contents {
|
||||
t.Errorf("file_read should return file contents verbatim; got %q want %q", got, contents)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_EmptyPath rejects empty input with a validation error rather
|
||||
// than silently reading "" / cwd. The dispatcher maps validation errors to
|
||||
// exit code 2 so the user sees a usage problem.
|
||||
func TestFileRead_EmptyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := ApplyTransform("", "file_read", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for empty path")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "file_read") {
|
||||
t.Errorf("error should mention the transform name, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_MissingFile surfaces a clear validation error when the path
|
||||
// doesn't exist. The previous `os.ReadFile` error is wrapped so the user
|
||||
// sees what they passed.
|
||||
func TestFileRead_MissingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
missing := filepath.Join(t.TempDir(), "definitely-not-here.md")
|
||||
_, err := ApplyTransform(missing, "file_read", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "definitely-not-here.md") {
|
||||
t.Errorf("error should mention the missing path, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_InvalidUTF8 rejects binary input. Upstream tools expect text
|
||||
// content and silently shipping a corrupted byte string would mask a real
|
||||
// user error.
|
||||
func TestFileRead_InvalidUTF8(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "binary.dat")
|
||||
if err := os.WriteFile(path, []byte{0xff, 0xfe, 0x00, 0x01}, 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
_, err := ApplyTransform(path, "file_read", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected UTF-8 validation error for binary input")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "UTF-8") {
|
||||
t.Errorf("error should mention UTF-8, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_NonString rejects non-string flag values. CLI flags resolve to
|
||||
// string by default but a misconfigured envelope (e.g. Type: int) shouldn't
|
||||
// silently no-op.
|
||||
func TestFileRead_NonString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := ApplyTransform(123, "file_read", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for non-string value")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_StdinDashIsAccepted documents the contract: the special value
|
||||
// "-" is reserved for stdin. We don't test stdin redirection here (that
|
||||
// requires plumbing os.Stdin replacement which complicates the test) — this
|
||||
// is a compile-time signal that "-" doesn't path-resolve to a file named "-"
|
||||
// in the current directory. The end-to-end stdin path is covered in
|
||||
// test/cli_compat once the envelope ships.
|
||||
func TestFileRead_StdinDashIsAccepted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Run with stdin redirected from an empty pipe so we don't hang.
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
if _, err := w.Write([]byte("piped content")); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
w.Close()
|
||||
|
||||
origStdin := os.Stdin
|
||||
os.Stdin = r
|
||||
defer func() { os.Stdin = origStdin }()
|
||||
|
||||
got, err := ApplyTransform("-", "file_read", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("file_read with '-' should read stdin, got err: %v", err)
|
||||
}
|
||||
if got != "piped content" {
|
||||
t.Errorf("expected stdin contents, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_UnknownTransformPassThrough double-checks that the new case
|
||||
// is gated by name and doesn't regress when the transform name is missing.
|
||||
func TestFileRead_UnknownTransformPassThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ApplyTransform("./some-path", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("empty transform should pass through, got err: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, "./some-path") {
|
||||
t.Errorf("expected pass-through, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvertBoolTransform(t *testing.T) {
|
||||
cases := []struct {
|
||||
in any
|
||||
want any
|
||||
}{
|
||||
{true, false},
|
||||
{false, true},
|
||||
{"true", false},
|
||||
{"false", true},
|
||||
{"True", false},
|
||||
{"FALSE", true},
|
||||
{"on", false},
|
||||
{"off", true},
|
||||
{"", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := ApplyTransform(c.in, "invert_bool", nil)
|
||||
if err != nil {
|
||||
t.Errorf("ApplyTransform(%v, invert_bool) err=%v", c.in, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("ApplyTransform(%v) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// 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
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// TestKindFromTypeName covers the schema v3 explicit Type field → ValueKind map.
|
||||
func TestKindFromTypeName(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in string
|
||||
want ValueKind
|
||||
}{
|
||||
{"", ValueString},
|
||||
{"string", ValueString},
|
||||
{"STRING", ValueString},
|
||||
{"int", ValueInt},
|
||||
{"integer", ValueInt},
|
||||
{"number", ValueInt},
|
||||
{"bool", ValueBool},
|
||||
{"boolean", ValueBool},
|
||||
{"stringSlice", ValueStringSlice},
|
||||
{"string_slice", ValueStringSlice},
|
||||
{"[]string", ValueStringSlice},
|
||||
{"weird-unknown-type", ValueString},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := kindFromTypeName(tc.in); got != tc.want {
|
||||
t.Errorf("kindFromTypeName(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyOmitWhen verifies all three modes plus zero-detection.
|
||||
func TestApplyOmitWhen(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("mode_zero_drops_zero_values", func(t *testing.T) {
|
||||
params := map[string]any{
|
||||
"a": 0,
|
||||
"b": "",
|
||||
"c": false,
|
||||
"d": []any{},
|
||||
"e": map[string]any{},
|
||||
"f": "non-empty",
|
||||
"g": 42,
|
||||
}
|
||||
for k := range params {
|
||||
applyOmitWhen(params, k, "zero")
|
||||
}
|
||||
for _, k := range []string{"a", "b", "c", "d", "e"} {
|
||||
if _, ok := params[k]; ok {
|
||||
t.Errorf("expected key %q to be dropped by omitWhen=zero", k)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"f", "g"} {
|
||||
if _, ok := params[k]; !ok {
|
||||
t.Errorf("expected key %q to be preserved", k)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mode_never_preserves_zero_values", func(t *testing.T) {
|
||||
params := map[string]any{"explicitFalse": false, "explicitZero": 0}
|
||||
applyOmitWhen(params, "explicitFalse", "never")
|
||||
applyOmitWhen(params, "explicitZero", "never")
|
||||
if len(params) != 2 {
|
||||
t.Errorf("expected both keys preserved under 'never', got %v", params)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mode_empty_is_noop", func(t *testing.T) {
|
||||
params := map[string]any{"a": ""}
|
||||
applyOmitWhen(params, "a", "empty")
|
||||
if _, ok := params["a"]; !ok {
|
||||
t.Errorf("empty mode should not drop keys here (upstream CollectBindings does)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing_key_safe", func(t *testing.T) {
|
||||
params := map[string]any{}
|
||||
applyOmitWhen(params, "nope", "zero")
|
||||
if len(params) != 0 {
|
||||
t.Errorf("expected no-op on missing key, got %v", params)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestIsZeroValue covers every branch of the helper.
|
||||
func TestIsZeroValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
zeros := []any{
|
||||
nil,
|
||||
"",
|
||||
" ",
|
||||
false,
|
||||
0,
|
||||
int64(0),
|
||||
float64(0),
|
||||
[]any{},
|
||||
[]string{},
|
||||
map[string]any{},
|
||||
}
|
||||
for i, z := range zeros {
|
||||
if !isZeroValue(z) {
|
||||
t.Errorf("case %d: expected zero value for %#v", i, z)
|
||||
}
|
||||
}
|
||||
nonZeros := []any{"x", true, 1, int64(1), float64(1.5), []any{1}, []string{"a"}, map[string]any{"k": 1}}
|
||||
for i, nz := range nonZeros {
|
||||
if isZeroValue(nz) {
|
||||
t.Errorf("case %d: expected non-zero for %#v", i, nz)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeDefaultResolvers_BuiltIns asserts $now and $today always resolve.
|
||||
func TestRuntimeDefaultResolvers_BuiltIns(t *testing.T) {
|
||||
// NOTE: not t.Parallel — edition.Get() global state is shared.
|
||||
resolvers := runtimeDefaultResolvers()
|
||||
now := resolvers["$now"]
|
||||
if now == nil {
|
||||
t.Fatal("$now resolver missing")
|
||||
}
|
||||
if v, ok := now(context.Background()); !ok || v == "" {
|
||||
t.Errorf("$now returned empty value: %q ok=%v", v, ok)
|
||||
}
|
||||
today := resolvers["$today"]
|
||||
if today == nil {
|
||||
t.Fatal("$today resolver missing")
|
||||
}
|
||||
if v, ok := today(context.Background()); !ok || !strings.Contains(v, "-") {
|
||||
t.Errorf("$today returned unexpected value: %q ok=%v", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeDefaultResolvers_OverlayMerge covers the edition overlay hook.
|
||||
func TestRuntimeDefaultResolvers_OverlayMerge(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
defer edition.Override(prev)
|
||||
|
||||
edition.Override(&edition.Hooks{
|
||||
RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
|
||||
return map[string]edition.RuntimeDefaultFn{
|
||||
"$currentUserId": func(ctx context.Context) (string, bool) {
|
||||
return "test-user-001", true
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
resolvers := runtimeDefaultResolvers()
|
||||
fn := resolvers["$currentUserId"]
|
||||
if fn == nil {
|
||||
t.Fatal("$currentUserId missing after overlay install")
|
||||
}
|
||||
if v, ok := fn(context.Background()); !ok || v != "test-user-001" {
|
||||
t.Errorf("$currentUserId=%q ok=%v", v, ok)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+63
-14
@@ -37,19 +37,20 @@ const (
|
||||
|
||||
// Error is the structured repository-local error model for the Go rewrite.
|
||||
type Error struct {
|
||||
Category Category
|
||||
Message string
|
||||
Operation string
|
||||
ServerKey string
|
||||
Retryable bool
|
||||
Reason string
|
||||
Hint string
|
||||
Actions []string
|
||||
Snapshot string
|
||||
RPCCode int `json:"rpc_code,omitempty"`
|
||||
RPCData json.RawMessage `json:"rpc_data,omitempty"`
|
||||
ServerDiag ServerDiagnostics `json:"-"`
|
||||
Cause error `json:"-"`
|
||||
Category Category
|
||||
Message string
|
||||
Operation string
|
||||
ServerKey string
|
||||
Retryable bool
|
||||
Reason string
|
||||
Hint string
|
||||
Actions []string
|
||||
AvailableFlags []string
|
||||
Snapshot string
|
||||
RPCCode int `json:"rpc_code,omitempty"`
|
||||
RPCData json.RawMessage `json:"rpc_data,omitempty"`
|
||||
ServerDiag ServerDiagnostics `json:"-"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
@@ -65,6 +66,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 +79,7 @@ func (e *Error) ExitCode() int {
|
||||
case CategoryValidation:
|
||||
return 3
|
||||
case CategoryDiscovery:
|
||||
return 4
|
||||
return 6
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
@@ -131,6 +136,16 @@ func WithActions(actions ...string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithAvailableFlags records visible local flag names for agent recovery.
|
||||
func WithAvailableFlags(names ...string) Option {
|
||||
return func(err *Error) {
|
||||
if len(names) == 0 {
|
||||
return
|
||||
}
|
||||
err.AvailableFlags = append([]string{}, names...)
|
||||
}
|
||||
}
|
||||
|
||||
// WithSnapshot records the recovery snapshot path associated with the failure.
|
||||
func WithSnapshot(path string) Option {
|
||||
return func(err *Error) {
|
||||
@@ -256,6 +271,9 @@ func PrintJSON(w io.Writer, err error) error {
|
||||
if len(typed.Actions) > 0 {
|
||||
errorPayload["actions"] = typed.Actions
|
||||
}
|
||||
if len(typed.AvailableFlags) > 0 {
|
||||
errorPayload["available_flags"] = typed.AvailableFlags
|
||||
}
|
||||
if typed.Snapshot != "" {
|
||||
errorPayload["snapshot_path"] = typed.Snapshot
|
||||
}
|
||||
@@ -355,6 +373,9 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
|
||||
lines = append(lines, fmt.Sprintf("Action: %s", action))
|
||||
}
|
||||
}
|
||||
if line := formatAvailableFlagsHumanLine(typed.AvailableFlags); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if typed.Retryable {
|
||||
lines = append(lines, "Retryable: true")
|
||||
}
|
||||
@@ -410,3 +431,31 @@ func category(err error) string {
|
||||
}
|
||||
return string(CategoryInternal)
|
||||
}
|
||||
|
||||
const availableFlagsHumanMaxRunes = 200
|
||||
|
||||
func formatAvailableFlagsHumanLine(flags []string) string {
|
||||
if len(flags) == 0 {
|
||||
return ""
|
||||
}
|
||||
b := strings.Builder{}
|
||||
b.WriteString("Flags: ")
|
||||
written := 0
|
||||
for i, name := range flags {
|
||||
if i > 0 {
|
||||
if written+2 > availableFlagsHumanMaxRunes {
|
||||
b.WriteString("...")
|
||||
return b.String()
|
||||
}
|
||||
b.WriteString(", ")
|
||||
written += 2
|
||||
}
|
||||
if written+len(name) > availableFlagsHumanMaxRunes {
|
||||
b.WriteString("...")
|
||||
return b.String()
|
||||
}
|
||||
b.WriteString(name)
|
||||
written += len(name)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
@@ -77,6 +77,27 @@ func TestPrintJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSON_AvailableFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewValidation(
|
||||
"unknown flag: --foo",
|
||||
WithReason("unknown_flag"),
|
||||
WithHint("Did you mean --bar?"),
|
||||
WithAvailableFlags("bar", "baz"),
|
||||
)); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
if !strings.Contains(got, `"available_flags"`) {
|
||||
t.Fatalf("expected available_flags in output, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"bar"`) || !strings.Contains(got, `"baz"`) {
|
||||
t.Fatalf("expected flag names in output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHuman(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+255
-29
@@ -17,16 +17,99 @@ import (
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"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.
|
||||
// New hosts may prefer data.authorizationUrl when present; it preserves data.uri
|
||||
// while adding a copy/open-safe URL for legacy DingTalk hash-route variants.
|
||||
type PATError struct {
|
||||
RawJSON string
|
||||
}
|
||||
@@ -49,9 +132,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 +160,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 +197,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 +246,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 +278,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 +332,119 @@ 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 rawURI, ok := data["uri"].(string); ok && strings.TrimSpace(rawURI) != "" {
|
||||
data["authorizationUrl"] = PATAuthorizationURL(rawURI)
|
||||
}
|
||||
if block := HostControlBlock(); block != nil {
|
||||
delete(data, "callbacks")
|
||||
data["hostControl"] = block
|
||||
}
|
||||
data["openBrowser"] = PATOpenBrowserValue()
|
||||
}
|
||||
|
||||
// PATAuthorizationURL returns the best URL for hosts to open or show to users.
|
||||
// It keeps already-complete PAT URLs unchanged. For DingTalk's legacy
|
||||
// /fe/old#%2FpersonalAuthorization?... hash-route form, it adds the explicit
|
||||
// hash query and decoded fragment route used by the working authorization page.
|
||||
func PATAuthorizationURL(rawURI string) string {
|
||||
rawURI = strings.TrimSpace(rawURI)
|
||||
if rawURI == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(rawURI)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return rawURI
|
||||
}
|
||||
if !strings.HasSuffix(parsed.Path, "/fe/old") {
|
||||
return rawURI
|
||||
}
|
||||
if parsed.Query().Get("hash") != "" && strings.Contains(parsed.Fragment, "personalAuthorization") {
|
||||
return rawURI
|
||||
}
|
||||
|
||||
routeQuery := patAuthorizationRouteQuery(parsed)
|
||||
if routeQuery.Get("flowId") == "" || routeQuery.Get("userCode") == "" {
|
||||
return rawURI
|
||||
}
|
||||
|
||||
route := "/personalAuthorization?" + routeQuery.Encode()
|
||||
|
||||
next := *parsed
|
||||
query := next.Query()
|
||||
query.Set("hash", "#"+route)
|
||||
next.RawQuery = query.Encode()
|
||||
next.Fragment = route
|
||||
next.RawFragment = ""
|
||||
return next.String()
|
||||
}
|
||||
|
||||
func patAuthorizationRouteQuery(parsed *url.URL) url.Values {
|
||||
candidates := []string{
|
||||
parsed.Fragment,
|
||||
parsed.RawFragment,
|
||||
parsed.Query().Get("hash"),
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if values := parsePersonalAuthorizationRouteQuery(candidate); values.Get("flowId") != "" && values.Get("userCode") != "" {
|
||||
return values
|
||||
}
|
||||
if decoded, err := url.QueryUnescape(candidate); err == nil && decoded != candidate {
|
||||
if values := parsePersonalAuthorizationRouteQuery(decoded); values.Get("flowId") != "" && values.Get("userCode") != "" {
|
||||
return values
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parsePersonalAuthorizationRouteQuery(route string) url.Values {
|
||||
route = strings.TrimSpace(route)
|
||||
route = strings.TrimPrefix(route, "#")
|
||||
idx := strings.Index(route, "personalAuthorization?")
|
||||
if idx < 0 {
|
||||
return nil
|
||||
}
|
||||
rawQuery := route[idx+len("personalAuthorization?"):]
|
||||
if cut := strings.IndexAny(rawQuery, "?#"); cut >= 0 {
|
||||
rawQuery = rawQuery[:cut]
|
||||
}
|
||||
values, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
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 +457,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 +474,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
|
||||
}
|
||||
|
||||
+383
-2
@@ -14,7 +14,9 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -143,8 +145,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 +245,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 +343,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 +429,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 +453,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 +556,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 +579,251 @@ 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)
|
||||
}
|
||||
if got, _ := data["authorizationUrl"].(string); got != rawURI {
|
||||
t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATAuthorizationURL_NormalizesLegacyHashRoute(t *testing.T) {
|
||||
t.Parallel()
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D77108a9d0e6f4b74b769c04eb451e7d9%26userCode%3DWSAX-EEF2"
|
||||
want := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D77108a9d0e6f4b74b769c04eb451e7d9%26userCode%3DWSAX-EEF2#/personalAuthorization?flowId=77108a9d0e6f4b74b769c04eb451e7d9&userCode=WSAX-EEF2"
|
||||
|
||||
if got := PATAuthorizationURL(rawURI); got != want {
|
||||
t.Fatalf("PATAuthorizationURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATAuthorizationURL_NormalizesLegacyHashRoutePreservesExtraQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D77108a9d0e6f4b74b769c04eb451e7d9%26userCode%3DWSAX-EEF2%26agentCode%3Dcodex%26scene%3Ddesktop%26redirect%3Dhttps%253A%252F%252Fexample.com%252Fcallback%253Fa%253D1"
|
||||
|
||||
got := PATAuthorizationURL(rawURI)
|
||||
|
||||
if got == rawURI {
|
||||
t.Fatal("expected legacy hash route to be normalized")
|
||||
}
|
||||
parsed, err := url.Parse(got)
|
||||
if err != nil {
|
||||
t.Fatalf("parse normalized URL: %v\nurl=%s", err, got)
|
||||
}
|
||||
hash := parsed.Query().Get("hash")
|
||||
if hash == "" {
|
||||
t.Fatalf("expected normalized URL to include hash query, got: %s", got)
|
||||
}
|
||||
if hash != "#"+parsed.Fragment {
|
||||
t.Fatalf("hash query = %q, want fragment route %q", hash, "#"+parsed.Fragment)
|
||||
}
|
||||
rawQuery, ok := strings.CutPrefix(parsed.Fragment, "/personalAuthorization?")
|
||||
if !ok {
|
||||
t.Fatalf("fragment = %q, want personalAuthorization route", parsed.Fragment)
|
||||
}
|
||||
values, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
t.Fatalf("parse normalized route query: %v\nquery=%s", err, rawQuery)
|
||||
}
|
||||
want := map[string]string{
|
||||
"flowId": "77108a9d0e6f4b74b769c04eb451e7d9",
|
||||
"userCode": "WSAX-EEF2",
|
||||
"agentCode": "codex",
|
||||
"scene": "desktop",
|
||||
"redirect": "https://example.com/callback?a=1",
|
||||
}
|
||||
for key, wantValue := range want {
|
||||
if gotValue := values.Get(key); gotValue != wantValue {
|
||||
t.Fatalf("route query %s = %q, want %q", key, gotValue, wantValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPATJSON_AddsNormalizedAuthorizationURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN"
|
||||
want := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN#/personalAuthorization?flowId=56b12fd3201d4efab9a9138672cf4deb&userCode=CFTC-27ZN"
|
||||
body := map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_MEDIUM_RISK_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"desc": "在浏览器中打开以下链接进行认证",
|
||||
"flowId": "56b12fd3201d4efab9a9138672cf4deb",
|
||||
"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)
|
||||
}
|
||||
if got, _ := data["authorizationUrl"].(string); got != want {
|
||||
t.Fatalf("data.authorizationUrl = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
}
|
||||
|
||||
+231
-282
@@ -15,6 +15,7 @@ package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
@@ -30,6 +31,12 @@ func init() {
|
||||
})
|
||||
}
|
||||
|
||||
// chatHandler retains only the chat commands that carry real business logic
|
||||
// (intelligent tool routing, current-user resolution, response normalization,
|
||||
// or stdin/@file input support that dynamic commands do not yet provide).
|
||||
// Thin wrappers — search, group rename, group members list/add/remove/add-bot,
|
||||
// bot search — are now produced by the dynamic service-discovery envelope
|
||||
// (envelope/pre-discovery.json) so the helper does not have to duplicate them.
|
||||
type chatHandler struct{}
|
||||
|
||||
func (chatHandler) Name() string {
|
||||
@@ -40,7 +47,7 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "chat",
|
||||
Short: "群聊 / 消息 / 机器人",
|
||||
Long: "管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取会话消息、发送群消息、机器人消息与 Webhook。",
|
||||
Long: "钉钉会话与群聊:发送消息(用户/机器人/Webhook)、撤回机器人消息、创建群。其余命令由服务发现 envelope 提供。",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
@@ -60,14 +67,16 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
}
|
||||
message.AddCommand(
|
||||
newChatMessageSendCommand(runner),
|
||||
newChatMessageSendByBotCommand(runner),
|
||||
newChatMessageRecallByBotCommand(runner),
|
||||
newChatMessageSendByWebhookCommand(runner),
|
||||
newChatMessageReplyCommand(runner),
|
||||
)
|
||||
|
||||
bot := &cobra.Command{
|
||||
Use: "bot",
|
||||
Short: "机器人管理",
|
||||
group := &cobra.Command{
|
||||
Use: "group",
|
||||
Short: "群组管理",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
@@ -75,12 +84,164 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
bot.AddCommand(newChatBotSearchCommand(runner))
|
||||
group.AddCommand(newChatGroupCreateCommand(runner))
|
||||
|
||||
root.AddCommand(message, newChatSearchCommand(runner), newChatGroupCommand(runner), bot)
|
||||
root.AddCommand(message, group)
|
||||
return root
|
||||
}
|
||||
|
||||
func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "send",
|
||||
Short: "以当前用户身份发送消息 (--group 群聊 / --user 或 --open-dingtalk-id 单聊)",
|
||||
Long: `以当前用户身份发送群消息或单聊消息。
|
||||
|
||||
--group 指定群聊 openConversationId 发群消息;--user 指定 userId 发单聊;
|
||||
--open-dingtalk-id 指定 openDingTalkId 发单聊 (适用于无法获取 userId 的场景)。
|
||||
三者只能选其一,不能同时指定。
|
||||
|
||||
消息内容通过 --text 传入,也可作为位置参数;支持 Markdown。
|
||||
--title 是消息标题,群聊与单聊都必填(API 强制要求;缺失时返回误导性的 "发群服务窗会话消息失败")。
|
||||
|
||||
群聊场景下可用 --at-all / --at-users / --at-mobiles 进行 @ 提醒(仅 --group 时生效)。
|
||||
注意 --text 中需包含对应的 <@userId> / <@all> 占位符才能在客户端渲染出 @ 效果。`,
|
||||
Example: ` dws chat message send --group <openconversation_id> --title "周报" --text "请提交本周日报"
|
||||
dws chat message send --user <userId> --title "提醒" --text "请查收"
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --title "提醒" --text "请确认"
|
||||
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 {
|
||||
params, tool, err := buildChatMessageSendInvocation(cmd, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
invocation := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
"chat",
|
||||
tool,
|
||||
params,
|
||||
)
|
||||
invocation.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), invocation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
|
||||
cmd.Flags().String("group", "", "群会话 openConversationId (群聊三选一)")
|
||||
cmd.Flags().String("user", "", "接收人 userId (单聊三选一)")
|
||||
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
|
||||
}
|
||||
|
||||
func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[string]any, string, error) {
|
||||
guard := cli.NewStdinGuard()
|
||||
|
||||
group, err := cmd.Flags().GetString("group")
|
||||
if err != nil {
|
||||
return nil, "", apperrors.NewInternal("failed to read --group")
|
||||
}
|
||||
user, err := cmd.Flags().GetString("user")
|
||||
if err != nil {
|
||||
return nil, "", apperrors.NewInternal("failed to read --user")
|
||||
}
|
||||
openID, err := cmd.Flags().GetString("open-dingtalk-id")
|
||||
if err != nil {
|
||||
return nil, "", apperrors.NewInternal("failed to read --open-dingtalk-id")
|
||||
}
|
||||
title, err := resolveStringFlag(cmd, "title", guard, false)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
// --text is the primary content flag: receives stdin pipe and positional
|
||||
// fallback when empty.
|
||||
text, err := resolveStringFlag(cmd, "text", guard, true)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if strings.TrimSpace(text) == "" && len(args) > 0 {
|
||||
text = args[0]
|
||||
}
|
||||
|
||||
hasGroup := strings.TrimSpace(group) != ""
|
||||
hasUser := strings.TrimSpace(user) != ""
|
||||
hasOpenID := strings.TrimSpace(openID) != ""
|
||||
specified := 0
|
||||
if hasGroup {
|
||||
specified++
|
||||
}
|
||||
if hasUser {
|
||||
specified++
|
||||
}
|
||||
if hasOpenID {
|
||||
specified++
|
||||
}
|
||||
switch specified {
|
||||
case 0:
|
||||
return nil, "", apperrors.NewValidation("one of --group, --user, or --open-dingtalk-id is required")
|
||||
case 1:
|
||||
default:
|
||||
return nil, "", apperrors.NewValidation("--group, --user, and --open-dingtalk-id are mutually exclusive")
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
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")
|
||||
}
|
||||
// Both send_message_as_user (group) and send_direct_message_as_user (direct)
|
||||
// reject an empty title at the API level with a misleading
|
||||
// "发群服务窗会话消息失败" error, so fail loudly here instead. The schema
|
||||
// declares title as a required parameter on both tools.
|
||||
if strings.TrimSpace(title) == "" {
|
||||
if hasGroup {
|
||||
return nil, "", apperrors.NewValidation("--title is required for group messages (--group)")
|
||||
}
|
||||
return nil, "", apperrors.NewValidation("--title is required for direct messages (--user / --open-dingtalk-id)")
|
||||
}
|
||||
|
||||
params := map[string]any{"text": text}
|
||||
if strings.TrimSpace(title) != "" {
|
||||
params["title"] = title
|
||||
}
|
||||
|
||||
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
|
||||
return params, "send_direct_message_as_user", nil
|
||||
default:
|
||||
params["receiverOpenDingTalkId"] = openID
|
||||
return params, "send_direct_message_as_user", nil
|
||||
}
|
||||
}
|
||||
|
||||
func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "send-by-bot",
|
||||
@@ -110,96 +271,13 @@ 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
|
||||
}
|
||||
|
||||
func newChatSearchCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "根据名称搜索会话列表",
|
||||
Example: ` dws chat search --query "项目冲刺"`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
query, err := cmd.Flags().GetString("query")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --query")
|
||||
}
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return apperrors.NewValidation("--query is required")
|
||||
}
|
||||
|
||||
searchReq := map[string]any{"query": query}
|
||||
cursor, err := cmd.Flags().GetString("cursor")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --cursor")
|
||||
}
|
||||
if strings.TrimSpace(cursor) != "" {
|
||||
searchReq["cursor"] = cursor
|
||||
}
|
||||
|
||||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
"chat",
|
||||
"search_groups_by_keyword",
|
||||
map[string]any{"OpenSearchRequest": searchReq},
|
||||
))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
|
||||
cmd.Flags().String("query", "", "搜索关键词 (必填)")
|
||||
cmd.Flags().String("cursor", "", "分页游标 (首页留空)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newChatGroupCommand(runner executor.Runner) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "group",
|
||||
Short: "群组管理",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
members := &cobra.Command{
|
||||
Use: "members",
|
||||
Short: "群成员管理",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: newChatGroupMembersListRunE(runner),
|
||||
}
|
||||
members.Flags().String("id", "", "群 ID / openconversation_id (必填)")
|
||||
members.Flags().String("cursor", "", "分页游标")
|
||||
preferLegacyLeaf(members)
|
||||
|
||||
members.AddCommand(
|
||||
newChatGroupMemberAddCommand(runner),
|
||||
newChatGroupMemberRemoveCommand(runner),
|
||||
newChatGroupMembersAddBotCommand(runner),
|
||||
)
|
||||
|
||||
root.AddCommand(
|
||||
newChatGroupCreateCommand(runner),
|
||||
members,
|
||||
newChatGroupRenameCommand(runner),
|
||||
)
|
||||
return root
|
||||
}
|
||||
|
||||
func newChatGroupCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
@@ -489,6 +567,10 @@ func newChatMessageRecallByBotCommand(runner executor.Runner) *cobra.Command {
|
||||
}
|
||||
|
||||
// ── message send-by-webhook ────────────────────────────────
|
||||
//
|
||||
// Kept as a helper (rather than delegating to the dynamic envelope) because
|
||||
// it needs --text @file / stdin pipe support via resolveStringFlag, which the
|
||||
// dynamic-command layer does not yet provide.
|
||||
|
||||
func newChatMessageSendByWebhookCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
@@ -555,54 +637,63 @@ func newChatMessageSendByWebhookCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── group members list ─────────────────────────────────────
|
||||
// ── message reply ────────────────────────────────────────
|
||||
//
|
||||
// Kept as a helper because the underlying MCP tool send_personal_message
|
||||
// requires the reply payload to be a JSON-encoded string assembled from
|
||||
// --ref-msg-id / --ref-sender / --text. Envelope toolOverride does flat
|
||||
// flag→param mapping only and cannot construct nested JSON, so this
|
||||
// orchestration must live in CLI code.
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// ── group rename ───────────────────────────────────────────
|
||||
|
||||
func newChatGroupRenameCommand(runner executor.Runner) *cobra.Command {
|
||||
func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "rename",
|
||||
Short: "更新群名称",
|
||||
Example: ` dws chat group rename --id <openconversation_id> --name "新群名"`,
|
||||
Use: "reply",
|
||||
Short: "引用回复消息(支持单聊/群聊)",
|
||||
Long: "以当前用户身份引用某条消息并回复。需 --conversation-id 会话 ID、--ref-msg-id 被引用消息 ID、--ref-sender 原发送者 openDingTalkId、--text 回复内容。",
|
||||
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
groupID, _ := cmd.Flags().GetString("id")
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
if strings.TrimSpace(groupID) == "" {
|
||||
return apperrors.NewValidation("--id is required")
|
||||
convID, _ := cmd.Flags().GetString("conversation-id")
|
||||
refMsgID, _ := cmd.Flags().GetString("ref-msg-id")
|
||||
refSender, _ := cmd.Flags().GetString("ref-sender")
|
||||
text, _ := cmd.Flags().GetString("text")
|
||||
if strings.TrimSpace(convID) == "" {
|
||||
return apperrors.NewValidation("--conversation-id is required")
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return apperrors.NewValidation("--name is required")
|
||||
if strings.TrimSpace(refMsgID) == "" {
|
||||
return apperrors.NewValidation("--ref-msg-id is required")
|
||||
}
|
||||
if strings.TrimSpace(refSender) == "" {
|
||||
return apperrors.NewValidation("--ref-sender is required")
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return apperrors.NewValidation("--text is required")
|
||||
}
|
||||
replyContent := map[string]any{
|
||||
"referenceOpenMessageId": refMsgID,
|
||||
"srcMsgSendOpenDingTalkId": refSender,
|
||||
"replyMsgType": "text",
|
||||
"content": text,
|
||||
}
|
||||
contentJSON, err := jsonMarshal(replyContent)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("marshal reply content: " + err.Error())
|
||||
}
|
||||
params := map[string]any{
|
||||
"openconversation_id": groupID,
|
||||
"group_name": name,
|
||||
"openConversationId": convID,
|
||||
"msgType": "reply",
|
||||
"content": contentJSON,
|
||||
"clawType": "wukong",
|
||||
}
|
||||
if uuid, _ := cmd.Flags().GetString("uuid"); strings.TrimSpace(uuid) != "" {
|
||||
params["uuid"] = uuid
|
||||
}
|
||||
inv := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "chat", "update_group_name", params,
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
"group-chat",
|
||||
"send_personal_message",
|
||||
params,
|
||||
)
|
||||
inv.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), inv)
|
||||
@@ -613,160 +704,18 @@ func newChatGroupRenameCommand(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
|
||||
cmd.Flags().String("name", "", "新群名称 (必填)")
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)")
|
||||
cmd.Flags().String("ref-msg-id", "", "被引用的消息 openMessageId (必填)")
|
||||
cmd.Flags().String("ref-sender", "", "被引用消息发送者 openDingTalkId (必填)")
|
||||
cmd.Flags().String("text", "", "回复正文 (必填)")
|
||||
cmd.Flags().String("uuid", "", "可选 uuid(幂等标识)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── group members add ──────────────────────────────────────
|
||||
|
||||
func newChatGroupMemberAddCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "添加群成员",
|
||||
Example: ` dws chat group members add --id <openconversation_id> --users userId1,userId2`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
groupID, _ := cmd.Flags().GetString("id")
|
||||
usersStr, _ := cmd.Flags().GetString("users")
|
||||
if strings.TrimSpace(groupID) == "" {
|
||||
return apperrors.NewValidation("--id is required")
|
||||
}
|
||||
if strings.TrimSpace(usersStr) == "" {
|
||||
return apperrors.NewValidation("--users is required")
|
||||
}
|
||||
params := map[string]any{
|
||||
"openconversation_id": groupID,
|
||||
"userId": splitCSV(usersStr),
|
||||
}
|
||||
inv := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "chat", "add_group_member", params,
|
||||
)
|
||||
inv.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
func jsonMarshal(v any) (string, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
|
||||
cmd.Flags().String("users", "", "要添加的 userId 列表,逗号分隔 (必填)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── group members remove ───────────────────────────────────
|
||||
|
||||
func newChatGroupMemberRemoveCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Short: "移除群成员",
|
||||
Example: ` dws chat group members remove --id <openconversation_id> --users userId1,userId2`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
groupID, _ := cmd.Flags().GetString("id")
|
||||
usersStr, _ := cmd.Flags().GetString("users")
|
||||
if strings.TrimSpace(groupID) == "" {
|
||||
return apperrors.NewValidation("--id is required")
|
||||
}
|
||||
if strings.TrimSpace(usersStr) == "" {
|
||||
return apperrors.NewValidation("--users is required")
|
||||
}
|
||||
params := map[string]any{
|
||||
"openconversationId": groupID,
|
||||
"userIdList": splitCSV(usersStr),
|
||||
}
|
||||
inv := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "chat", "remove_group_member", params,
|
||||
)
|
||||
inv.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("id", "", "Group ID / openconversation_id (required)")
|
||||
cmd.Flags().String("users", "", "Comma-separated userId list to remove (required)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── group members add-bot ──────────────────────────────────
|
||||
|
||||
func newChatGroupMembersAddBotCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add-bot",
|
||||
Short: "Add bot to group",
|
||||
Example: ` dws chat group members add-bot --robot-code <robot-code> --id <openconversation_id>`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
robotCode, _ := cmd.Flags().GetString("robot-code")
|
||||
groupID, _ := cmd.Flags().GetString("id")
|
||||
if strings.TrimSpace(robotCode) == "" {
|
||||
return apperrors.NewValidation("--robot-code is required")
|
||||
}
|
||||
if strings.TrimSpace(groupID) == "" {
|
||||
return apperrors.NewValidation("--id is required")
|
||||
}
|
||||
params := map[string]any{
|
||||
"robotCode": robotCode,
|
||||
"openConversationId": groupID,
|
||||
}
|
||||
inv := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "bot", "add_robot_to_group", params,
|
||||
)
|
||||
inv.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("robot-code", "", "Bot code (required)")
|
||||
cmd.Flags().String("id", "", "Group openConversationId (required)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── bot search ─────────────────────────────────────────────
|
||||
|
||||
func newChatBotSearchCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "Search my bots",
|
||||
Example: " dws chat bot search --page 1\n dws chat bot search --page 1 --size 10 --name \"日报\"",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
page, _ := cmd.Flags().GetInt("page")
|
||||
params := map[string]any{
|
||||
"currentPage": page,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("size"); v > 0 {
|
||||
params["pageSize"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("name"); v != "" {
|
||||
params["robotName"] = v
|
||||
}
|
||||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "bot", "search_my_robots", params,
|
||||
))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().Int("page", 1, "Page number, starting from 1")
|
||||
cmd.Flags().Int("size", 0, "Items per page (default 50)")
|
||||
cmd.Flags().String("name", "", "Search by name")
|
||||
return cmd
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package helpers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
@@ -48,6 +49,263 @@ func TestChatMessageSendByBotIgnoresLegacyRealBuildModeEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageSendRoutesByDestination(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantKey string
|
||||
wantValue string
|
||||
}{
|
||||
{
|
||||
name: "group",
|
||||
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "hello"},
|
||||
wantTool: "send_message_as_user",
|
||||
wantKey: "openConversation_id",
|
||||
wantValue: "cid-xyz",
|
||||
},
|
||||
{
|
||||
name: "user-direct",
|
||||
args: []string{"--user", "034766", "--title", "t", "--text", "hi"},
|
||||
wantTool: "send_direct_message_as_user",
|
||||
wantKey: "receiverUserId",
|
||||
wantValue: "034766",
|
||||
},
|
||||
{
|
||||
name: "open-dingtalk-id-direct",
|
||||
args: []string{"--open-dingtalk-id", "OP123", "--title", "t", "--text", "hi"},
|
||||
wantTool: "send_direct_message_as_user",
|
||||
wantKey: "receiverOpenDingTalkId",
|
||||
wantValue: "OP123",
|
||||
},
|
||||
{
|
||||
name: "positional-text",
|
||||
args: []string{"--group", "cid-xyz", "--title", "t", "hello from positional"},
|
||||
wantTool: "send_message_as_user",
|
||||
wantKey: "text",
|
||||
wantValue: "hello from positional",
|
||||
},
|
||||
}
|
||||
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 != tc.wantTool {
|
||||
t.Fatalf("Tool = %q, want %q", got, tc.wantTool)
|
||||
}
|
||||
if got := runner.last.CanonicalProduct; got != "chat" {
|
||||
t.Fatalf("CanonicalProduct = %q, want chat", got)
|
||||
}
|
||||
if got, ok := runner.last.Params[tc.wantKey]; !ok || got != tc.wantValue {
|
||||
t.Fatalf("Params[%q] = %#v, want %q", tc.wantKey, got, tc.wantValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageSendRejectsInvalidDestination(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "no-destination",
|
||||
args: []string{"--text", "hi"},
|
||||
wantErr: "one of --group, --user, or --open-dingtalk-id is required",
|
||||
},
|
||||
{
|
||||
name: "group-and-user",
|
||||
args: []string{"--group", "cid-x", "--user", "034766", "--text", "hi"},
|
||||
wantErr: "--group, --user, and --open-dingtalk-id are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "empty-text",
|
||||
args: []string{"--group", "cid-x"},
|
||||
wantErr: "--text (or positional argument) is required",
|
||||
},
|
||||
{
|
||||
name: "group-without-title",
|
||||
args: []string{"--group", "cid-x", "--text", "hi"},
|
||||
wantErr: "--title is required for group messages",
|
||||
},
|
||||
{
|
||||
name: "direct-user-without-title",
|
||||
args: []string{"--user", "034766", "--text", "hi"},
|
||||
wantErr: "--title is required for direct messages",
|
||||
},
|
||||
{
|
||||
name: "direct-open-dingtalk-id-without-title",
|
||||
args: []string{"--open-dingtalk-id", "OP123", "--text", "hi"},
|
||||
wantErr: "--title is required for direct messages",
|
||||
},
|
||||
}
|
||||
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 got := err.Error(); !strings.Contains(got, tc.wantErr) {
|
||||
t.Fatalf("error = %q, want to contain %q", got, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
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,65 @@
|
||||
// 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.
|
||||
|
||||
//go:build darwin || linux
|
||||
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// fileDEK retrieves or generates a Data Encryption Key stored as a plain
|
||||
// file under the platform storage directory. Shared by Linux (default) and
|
||||
// the macOS sandbox fallback path (DWS_DISABLE_KEYCHAIN=1).
|
||||
func fileDEK(service string) ([]byte, error) {
|
||||
dir := StorageDir(service)
|
||||
keyPath := filepath.Join(dir, "dek")
|
||||
|
||||
key, err := os.ReadFile(keyPath)
|
||||
if err == nil && len(key) == dekBytes {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("create keychain dir: %w", err)
|
||||
}
|
||||
|
||||
key = make([]byte, dekBytes)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, fmt.Errorf("generate dek: %w", err)
|
||||
}
|
||||
|
||||
tmpKeyPath := filepath.Join(dir, "dek."+uuid.New().String()+".tmp")
|
||||
defer os.Remove(tmpKeyPath)
|
||||
|
||||
if err := os.WriteFile(tmpKeyPath, key, 0600); err != nil {
|
||||
return nil, fmt.Errorf("write dek: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpKeyPath, keyPath); err != nil {
|
||||
// If rename fails, another process might have created it. Try reading again.
|
||||
existingKey, readErr := os.ReadFile(keyPath)
|
||||
if readErr == nil && len(existingKey) == dekBytes {
|
||||
return existingKey, nil
|
||||
}
|
||||
return nil, fmt.Errorf("save dek: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
@@ -23,6 +23,21 @@ const (
|
||||
|
||||
// AccountToken is the account key for storing auth token data.
|
||||
AccountToken = "auth-token"
|
||||
|
||||
// StorageDirEnv overrides the on-disk keychain storage root on
|
||||
// platforms that use file-backed storage (macOS, Linux). It is
|
||||
// intended for tests that need to isolate keychain state from the
|
||||
// real user environment and from sibling test packages running in
|
||||
// parallel. When empty, the platform default applies.
|
||||
StorageDirEnv = "DWS_KEYCHAIN_DIR"
|
||||
|
||||
// DisableKeychainEnv opts the macOS implementation out of system
|
||||
// Keychain access for the DEK, falling back to a file-based DEK
|
||||
// (same scheme as Linux). Intended for sandboxed runtimes where
|
||||
// Keychain APIs are blocked (e.g. Codex App). This weakens the
|
||||
// at-rest protection — DEK and ciphertext live in the same
|
||||
// directory — and is therefore opt-in.
|
||||
DisableKeychainEnv = "DWS_DISABLE_KEYCHAIN"
|
||||
)
|
||||
|
||||
// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection.
|
||||
|
||||
@@ -40,7 +40,12 @@ const (
|
||||
|
||||
// StorageDir returns the storage directory for a given service name on macOS.
|
||||
// Uses ~/Library/Application Support/<service> following Apple conventions.
|
||||
// When the DWS_KEYCHAIN_DIR environment variable is set (used by tests for
|
||||
// isolation), the storage root is taken from that env var instead.
|
||||
func StorageDir(service string) string {
|
||||
if override := os.Getenv(StorageDirEnv); override != "" {
|
||||
return filepath.Join(override, service)
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return filepath.Join(".dws", "keychain", service)
|
||||
@@ -54,8 +59,16 @@ func safeFileName(account string) string {
|
||||
return safeFileNameRe.ReplaceAllString(account, "_") + ".enc"
|
||||
}
|
||||
|
||||
// getDEK retrieves or generates the Data Encryption Key from system Keychain.
|
||||
// getDEK retrieves or generates the Data Encryption Key.
|
||||
// When DWS_DISABLE_KEYCHAIN=1 (set in sandboxed runtimes like Codex App
|
||||
// where Keychain APIs are blocked), falls back to a file-based DEK
|
||||
// identical to the Linux scheme. See DisableKeychainEnv docs for the
|
||||
// security tradeoff.
|
||||
func getDEK(service string) ([]byte, error) {
|
||||
if os.Getenv(DisableKeychainEnv) != "" {
|
||||
return fileDEK(service)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), keychainTimeout)
|
||||
defer cancel()
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// 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.
|
||||
|
||||
//go:build darwin
|
||||
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDisableKeychainFallback verifies that setting DWS_DISABLE_KEYCHAIN
|
||||
// routes the DEK to a local file (same scheme as Linux) and the full
|
||||
// Set/Get/Remove cycle works without touching the system Keychain.
|
||||
// This is the support path for sandboxed runtimes such as Codex App.
|
||||
func TestDisableKeychainFallback(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv(StorageDirEnv, tmp)
|
||||
t.Setenv(DisableKeychainEnv, "1")
|
||||
|
||||
service := "test-disable-keychain"
|
||||
account := "auth-token"
|
||||
payload := `{"access_token":"abc","refresh_token":"def"}`
|
||||
|
||||
if err := Set(service, account, payload); err != nil {
|
||||
t.Fatalf("Set() error = %v", err)
|
||||
}
|
||||
|
||||
// File DEK must materialize on disk.
|
||||
dekPath := filepath.Join(tmp, service, "dek")
|
||||
info, err := os.Stat(dekPath)
|
||||
if err != nil {
|
||||
t.Fatalf("file DEK not created at %s: %v", dekPath, err)
|
||||
}
|
||||
if mode := info.Mode().Perm(); mode != 0600 {
|
||||
t.Fatalf("DEK file perm = %o, want 0600", mode)
|
||||
}
|
||||
|
||||
got, err := Get(service, account)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
if got != payload {
|
||||
t.Fatalf("Get() = %q, want %q", got, payload)
|
||||
}
|
||||
|
||||
// A second Get must reuse the same DEK (no regeneration).
|
||||
dek1, err := os.ReadFile(dekPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(dek) error = %v", err)
|
||||
}
|
||||
if _, err := Get(service, account); err != nil {
|
||||
t.Fatalf("second Get() error = %v", err)
|
||||
}
|
||||
dek2, err := os.ReadFile(dekPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(dek) second error = %v", err)
|
||||
}
|
||||
if string(dek1) != string(dek2) {
|
||||
t.Fatal("DEK rotated between calls; want stable")
|
||||
}
|
||||
|
||||
if err := Remove(service, account); err != nil {
|
||||
t.Fatalf("Remove() error = %v", err)
|
||||
}
|
||||
if Exists(service, account) {
|
||||
t.Fatal("Exists() = true after Remove(), want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisableKeychainOverwrite verifies the fallback path supports
|
||||
// overwriting an existing token entry.
|
||||
func TestDisableKeychainOverwrite(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv(StorageDirEnv, tmp)
|
||||
t.Setenv(DisableKeychainEnv, "1")
|
||||
|
||||
service := "test-disable-keychain-overwrite"
|
||||
account := "auth-token"
|
||||
|
||||
if err := Set(service, account, "initial"); err != nil {
|
||||
t.Fatalf("Set() initial error = %v", err)
|
||||
}
|
||||
if err := Set(service, account, "overwritten"); err != nil {
|
||||
t.Fatalf("Set() overwrite error = %v", err)
|
||||
}
|
||||
|
||||
got, err := Get(service, account)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
if got != "overwritten" {
|
||||
t.Fatalf("Get() = %q, want %q", got, "overwritten")
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,11 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// getDEK retrieves or generates the Data Encryption Key from local file.
|
||||
func getDEK(service string) ([]byte, error) {
|
||||
return fileDEK(service)
|
||||
}
|
||||
|
||||
const (
|
||||
dekBytes = 32 // DEK = Data Encryption Key (AES-256)
|
||||
ivBytes = 12
|
||||
@@ -34,8 +39,13 @@ const (
|
||||
)
|
||||
|
||||
// StorageDir returns the storage directory for a given service name.
|
||||
// Follows XDG Base Directory Specification: ~/.local/share/<service>
|
||||
// Follows XDG Base Directory Specification: ~/.local/share/<service>.
|
||||
// When the DWS_KEYCHAIN_DIR environment variable is set (used by tests for
|
||||
// isolation), the storage root is taken from that env var instead.
|
||||
func StorageDir(service string) string {
|
||||
if override := os.Getenv(StorageDirEnv); override != "" {
|
||||
return filepath.Join(override, service)
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
|
||||
@@ -51,48 +61,6 @@ func safeFileName(account string) string {
|
||||
return safeFileNameRe.ReplaceAllString(account, "_") + ".enc"
|
||||
}
|
||||
|
||||
// getDEK retrieves or generates the Data Encryption Key from local file.
|
||||
func getDEK(service string) ([]byte, error) {
|
||||
dir := StorageDir(service)
|
||||
keyPath := filepath.Join(dir, "dek")
|
||||
|
||||
// Try to read existing DEK
|
||||
key, err := os.ReadFile(keyPath)
|
||||
if err == nil && len(key) == dekBytes {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("create keychain dir: %w", err)
|
||||
}
|
||||
|
||||
// Generate new random DEK
|
||||
key = make([]byte, dekBytes)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, fmt.Errorf("generate dek: %w", err)
|
||||
}
|
||||
|
||||
// Atomic write to prevent multi-process initialization collision
|
||||
tmpKeyPath := filepath.Join(dir, "dek."+uuid.New().String()+".tmp")
|
||||
defer os.Remove(tmpKeyPath)
|
||||
|
||||
if err := os.WriteFile(tmpKeyPath, key, 0600); err != nil {
|
||||
return nil, fmt.Errorf("write dek: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpKeyPath, keyPath); err != nil {
|
||||
// If rename fails, another process might have created it. Try reading again.
|
||||
existingKey, readErr := os.ReadFile(keyPath)
|
||||
if readErr == nil && len(existingKey) == dekBytes {
|
||||
return existingKey, nil
|
||||
}
|
||||
return nil, fmt.Errorf("save dek: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func encryptData(plaintext string, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
|
||||
+267
-14
@@ -38,6 +38,7 @@ const (
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
type ListResponse struct {
|
||||
@@ -48,6 +49,26 @@ type ListResponse struct {
|
||||
type ListMetadata struct {
|
||||
Count int `json:"count"`
|
||||
NextCursor string `json:"nextCursor"`
|
||||
// Warnings is populated by the Portal merge service when envelopes are
|
||||
// dropped (e.g. missing serverDeps, status != active) or when a dangling
|
||||
// serverDeps / toolOverrides.*.serverOverride reference is detected.
|
||||
// Old Portals without the field simply leave this nil. CLI side should
|
||||
// treat any non-empty slice as non-fatal informational output: print to
|
||||
// stderr so cache refreshes expose Portal drift to the user, but do not
|
||||
// fail the discovery load.
|
||||
//
|
||||
// See plan fix-wukong-discovery-missing-servers Phase 4.2/4.3.
|
||||
Warnings []ListWarning `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// ListWarning describes one envelope that was filtered out of the merged
|
||||
// response or flagged for dangling references. Fields mirror the JSON emitted
|
||||
// by WukongDiscoveryRegistry.buildWarning on the Portal side; unknown reason
|
||||
// codes are passed through verbatim for forward compatibility.
|
||||
type ListWarning struct {
|
||||
ProductID string `json:"productId"`
|
||||
Reason string `json:"reason"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
type ServerEnvelope struct {
|
||||
@@ -97,6 +118,37 @@ type CLIOverlay struct {
|
||||
Tools []CLITool `json:"tools"`
|
||||
Groups map[string]CLIGroupDef `json:"groups,omitempty"`
|
||||
ToolOverrides map[string]CLIToolOverride `json:"toolOverrides,omitempty"`
|
||||
// ServerDeps declares other product IDs that this overlay depends on at
|
||||
// runtime (e.g. chat depends on bot for cross-server tool routing).
|
||||
// Consumed by the portal merge service for fail-fast validation; CLI side
|
||||
// currently only stores the value for tooling/introspection.
|
||||
ServerDeps []string `json:"serverDeps,omitempty"`
|
||||
// Hints registers stub sub-commands under the overlay root that only
|
||||
// print a redirect message pointing to the canonical command path. Used
|
||||
// for deprecated command aliases and "did-you-mean" style hints.
|
||||
// Key is the sub-command name; value describes the target path.
|
||||
Hints map[string]CLIHintDef `json:"hintCommands,omitempty"`
|
||||
// RedirectTo, when non-empty, turns the entire top-level product into a
|
||||
// stub that prints "Please use: dws <target>" and performs no work. Used
|
||||
// for deprecated top-level products migrated to new paths (e.g.
|
||||
// `bot → chat bot`, `message → chat message`). See schema v3 §2.6.
|
||||
RedirectTo string `json:"redirectTo,omitempty"`
|
||||
}
|
||||
|
||||
// CLIHintDef declares a stub sub-command that prints a redirect message.
|
||||
// The command takes no bindings and calls no tool; its sole purpose is to
|
||||
// help users migrate from an old command path to the new one.
|
||||
type CLIHintDef struct {
|
||||
// Target is the canonical command path shown in the redirect message
|
||||
// (e.g. "dws chat message list").
|
||||
Target string `json:"target"`
|
||||
// Description overrides the Short/Long help text for the hint command.
|
||||
// Empty falls back to a generic "use: <target>" string.
|
||||
Description string `json:"description,omitempty"`
|
||||
// Group optionally nests the hint under a named sub-group (same syntax
|
||||
// as CLIToolOverride.Group with dot-separated paths). Empty means the
|
||||
// hint is attached directly to the overlay root.
|
||||
Group string `json:"group,omitempty"`
|
||||
}
|
||||
|
||||
// CLIGroupDef defines a sub-command group within a CLI module.
|
||||
@@ -104,25 +156,187 @@ type CLIGroupDef struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// CLIOutputFormat declares structured post-processing applied to the MCP tool
|
||||
// response before the formatter prints it. See schema v3 §2.5.
|
||||
//
|
||||
// Apply order: Drop → Rename → Columns. All three are optional.
|
||||
type CLIOutputFormat struct {
|
||||
// Rename moves fields from src key to dst key at top level and one level
|
||||
// of nested objects. Missing src keys are silently ignored.
|
||||
Rename map[string]string `json:"rename,omitempty"`
|
||||
// Drop removes these keys from the response (top level + one level deep).
|
||||
Drop []string `json:"drop,omitempty"`
|
||||
// Columns controls column order and subset for --format=table. Ignored in
|
||||
// JSON output mode.
|
||||
Columns []string `json:"columns,omitempty"`
|
||||
}
|
||||
|
||||
// CLIToolOverride maps an MCP tool to a CLI command with flag aliases and transforms.
|
||||
type CLIToolOverride struct {
|
||||
CLIName string `json:"cliName"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
IsSensitive bool `json:"isSensitive,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
Flags map[string]CLIFlagOverride `json:"flags,omitempty"`
|
||||
OutputFormat map[string]any `json:"outputFormat,omitempty"`
|
||||
CLIName string `json:"cliName"`
|
||||
// CLIAliases registers additional cobra command aliases for the same MCP
|
||||
// tool, so the leaf command can be invoked under multiple names without
|
||||
// duplicating the override. Mirrors cobra.Command.Aliases. Each alias is
|
||||
// added to the cobra Aliases slice; conflicts with existing siblings are
|
||||
// silently ignored by cobra. Use for command-name normalisation (e.g.
|
||||
// `range read` accepts `range get` as an alias) or hardcoded-command
|
||||
// migration paths. Empty / nil means no extra aliases.
|
||||
CLIAliases []string `json:"cliAliases,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
// Example, when non-empty, is wired to cobra.Command.Example to render
|
||||
// the "Examples:" section in --help. Mirrors hardcoded helper commands'
|
||||
// Example field (e.g. wukong/products/oa.go list-forms). Empty value
|
||||
// produces no Examples section. Multi-line strings keep "\n" literally.
|
||||
Example string `json:"example,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
IsSensitive bool `json:"isSensitive,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
Flags map[string]CLIFlagOverride `json:"flags,omitempty"`
|
||||
// OutputFormat declares structured response post-processing. v3 typed form
|
||||
// supersedes v2's untyped map[string]any, but parsing stays lenient so v2
|
||||
// envelopes continue to deserialize (unknown keys are ignored).
|
||||
OutputFormat CLIOutputFormat `json:"outputFormat,omitempty"`
|
||||
// ServerOverride routes this leaf command's tool invocation to a different
|
||||
// product's MCP server (e.g. `chat bot ...` leaves live under the `chat`
|
||||
// command tree but call the `bot` endpoint). Empty means use the enclosing
|
||||
// overlay's server.
|
||||
ServerOverride string `json:"serverOverride,omitempty"`
|
||||
// BodyWrapper, when non-empty, wraps the collected params map under this
|
||||
// single key before the invocation is dispatched. Useful when the upstream
|
||||
// tool expects a typed DTO wrapper (e.g. `PersonalTodoCreateVO`). Only
|
||||
// user-provided params are wrapped; internal control keys starting with
|
||||
// '_' (e.g. `_blocked`, `_yes`) are preserved at the top level.
|
||||
BodyWrapper string `json:"bodyWrapper,omitempty"`
|
||||
// MutuallyExclusive groups flag aliases that must not be set together.
|
||||
// Each inner slice becomes one cobra.MarkFlagsMutuallyExclusive call.
|
||||
// Example: [["group","user","open-dingtalk-id"]] for `chat message list`.
|
||||
MutuallyExclusive [][]string `json:"mutuallyExclusive,omitempty"`
|
||||
// RequireOneOf groups flag aliases where at least one must be set. Each
|
||||
// inner slice becomes one cobra.MarkFlagsOneRequired call. Typically
|
||||
// paired with MutuallyExclusive to enforce "exactly one of".
|
||||
RequireOneOf [][]string `json:"requireOneOf,omitempty"`
|
||||
// RedirectTo, when non-empty, turns this entry into a stub command that
|
||||
// prints the redirect target instead of invoking a tool. All other
|
||||
// fields (Flags / BodyWrapper / IsSensitive / ServerOverride) are
|
||||
// ignored. Use for deprecated leaf commands that moved to a new path.
|
||||
RedirectTo string `json:"redirectTo,omitempty"`
|
||||
// Pipeline declares a multi-step orchestration: each step calls one
|
||||
// MCP tool, with subsequent steps able to reference prior step outputs
|
||||
// in their argument templates. PollUntilField/Value turn a step into a
|
||||
// polling loop (for async jobs); type:"download" turns a step into an
|
||||
// HTTP download sink that writes to a CLI-local --output flag. When
|
||||
// Pipeline is non-empty, dispatch ignores the parent toolOverrides map
|
||||
// key (no single "primary tool"); the executor walks the steps in
|
||||
// order. CLI surface (CLIName / Group / Flags) still comes from the
|
||||
// parent override; flags can be marked PipelineLocal=true to be
|
||||
// consumed by the executor without being forwarded to MCP tools.
|
||||
//
|
||||
// See internal/compat/pipeline.go for the executor + envelope examples.
|
||||
Pipeline []PipelineStep `json:"pipeline,omitempty"`
|
||||
}
|
||||
|
||||
// PipelineStep declares one step in a multi-step CLIToolOverride.Pipeline.
|
||||
// Templates supported in Args / DownloadURLField:
|
||||
//
|
||||
// $flag.<aliasName> — value of the user's CLI flag whose alias is
|
||||
// <aliasName> (resolved at dispatch time).
|
||||
// $step.<idx>.<dotPath> — field from a prior step's response, e.g.
|
||||
// "$step.0.jobId" or "$step.1.result.url".
|
||||
// literal value — passed through unchanged.
|
||||
type PipelineStep struct {
|
||||
// Type controls dispatch. Empty / "call" invokes Tool as an MCP tool.
|
||||
// "download" treats this step as an HTTP GET sink (no MCP tool is
|
||||
// invoked); URL is resolved from DownloadURLField.
|
||||
Type string `json:"type,omitempty"`
|
||||
// Tool is the MCP tool name to invoke for type=="call".
|
||||
Tool string `json:"tool,omitempty"`
|
||||
// Args maps MCP tool parameter names to template strings.
|
||||
Args map[string]string `json:"args,omitempty"`
|
||||
// PollUntilField, when non-empty (with PollUntilValue), turns this
|
||||
// step into a polling loop: invoke repeatedly with the same Args
|
||||
// until response[<PollUntilField>] equals PollUntilValue (string
|
||||
// compare). Use for async-job patterns where a status field
|
||||
// transitions to a terminal value (e.g. "done" / "succeeded").
|
||||
PollUntilField string `json:"pollUntilField,omitempty"`
|
||||
PollUntilValue string `json:"pollUntilValue,omitempty"`
|
||||
PollIntervalSec int `json:"pollIntervalSec,omitempty"` // default 2 when polling
|
||||
PollTimeoutSec int `json:"pollTimeoutSec,omitempty"` // default 300 when polling
|
||||
// DownloadURLField (type=="download") is a $step.X.field template
|
||||
// that resolves to an HTTP URL. The body is fetched via GET and
|
||||
// written to the path given by OutputFlag's value. If OutputFlag's
|
||||
// value is empty, the URL is printed to stdout for the user.
|
||||
DownloadURLField string `json:"downloadURLField,omitempty"`
|
||||
// OutputFlag (type=="download") names the CLI flag (alias) whose
|
||||
// user-supplied value is the local destination path. When the path
|
||||
// is a directory, the filename is inferred from the URL's basename.
|
||||
OutputFlag string `json:"outputFlag,omitempty"`
|
||||
}
|
||||
|
||||
// CLIFlagOverride describes how to map an MCP parameter to a CLI flag.
|
||||
type CLIFlagOverride struct {
|
||||
Alias string `json:"alias"`
|
||||
Alias string `json:"alias"`
|
||||
// Aliases registers additional hidden CLI flag names for the same MCP
|
||||
// parameter. Use to preserve legacy flag names when migrating from
|
||||
// hardcoded commands (mirrors cmdutil.ValidateRequiredFlagWithAliases /
|
||||
// cmdutil.FlagOrFallback). All entries are registered as hidden flags
|
||||
// (not shown in --help); values are deduped against the primary flag
|
||||
// name and Alias, and reserved names ("json", "params") are skipped.
|
||||
// When any alias is set by the user, the binding's Required check is
|
||||
// satisfied and the value is written to params[Property].
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
// MapsTo redirects this flag's final value into a different MCP parameter
|
||||
// slot. When empty (default), the value is written to params[propertyName]
|
||||
// as today. When set, params[MapsTo] receives the (possibly transformed)
|
||||
// value and params[propertyName] is NOT written. This is what lets a
|
||||
// sibling CLI flag — e.g. --content-file with transform: file_read — feed
|
||||
// the same MCP parameter (markdown) as the existing literal --content
|
||||
// flag, without forcing one flag to do double-duty.
|
||||
//
|
||||
// Pair with the existing CLIToolOverride.MutuallyExclusive (tool-level
|
||||
// cobra MarkFlagsMutuallyExclusive) when two sibling flags map to the
|
||||
// same MCP slot but should not be set together.
|
||||
MapsTo string `json:"mapsTo,omitempty"`
|
||||
Transform string `json:"transform,omitempty"`
|
||||
TransformArgs map[string]any `json:"transformArgs,omitempty"`
|
||||
EnvDefault string `json:"envDefault,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
Default string `json:"default,omitempty"`
|
||||
// Shorthand registers a single-char flag alias (cobra StringP etc.).
|
||||
Shorthand string `json:"shorthand,omitempty"`
|
||||
// Required marks this flag as mandatory via cobra.MarkFlagRequired.
|
||||
// Ignored when Positional is true (positional args have their own arity rules).
|
||||
Required bool `json:"required,omitempty"`
|
||||
// Description overrides the usage string displayed in --help; takes
|
||||
// priority over the Detail API's toolDesc when non-empty.
|
||||
Description string `json:"description,omitempty"`
|
||||
// Positional, when true, binds this parameter to a positional CLI argument
|
||||
// instead of a --flag. PositionalIndex (0-based) selects which arg slot.
|
||||
Positional bool `json:"positional,omitempty"`
|
||||
PositionalIndex int `json:"positionalIndex,omitempty"`
|
||||
// Type explicitly declares the flag's type: "string" (default) / "int" /
|
||||
// "bool" / "stringSlice". When set, overrides the type inferred from MCP
|
||||
// tools/list inputSchema. See schema v3 §2.1.
|
||||
Type string `json:"type,omitempty"`
|
||||
// OmitWhen declares empty-value handling when building the invocation body:
|
||||
//
|
||||
// "empty" (default): empty string / zero-length slice-or-map → omit
|
||||
// "zero": + zero numbers / false booleans → omit
|
||||
// "never": always send, even at zero value (explicit-zero semantics)
|
||||
//
|
||||
// See schema v3 §2.2.
|
||||
OmitWhen string `json:"omitWhen,omitempty"`
|
||||
// RuntimeDefault, when non-empty, injects a runtime-resolved value if the
|
||||
// user omits the flag. Allowed placeholders: "$currentUserId" / "$unionId"
|
||||
// / "$corpId" / "$now" / "$today". Unknown placeholders → warning + skip.
|
||||
// Resolution comes from edition.Hooks.RuntimeDefaults; open-source core
|
||||
// only recognises the placeholder set. See schema v3 §2.3.
|
||||
RuntimeDefault string `json:"runtimeDefault,omitempty"`
|
||||
// PipelineLocal, when true, marks this flag as CLI-side only — its
|
||||
// value is consumed by the pipeline executor (e.g. as an HTTP
|
||||
// download destination) and NOT forwarded to any MCP tool's params.
|
||||
// Only meaningful when the enclosing CLIToolOverride.Pipeline is set.
|
||||
// Use for flags like `--output` that describe local destination paths.
|
||||
PipelineLocal bool `json:"pipelineLocal,omitempty"`
|
||||
}
|
||||
|
||||
type CLITool struct {
|
||||
@@ -256,6 +470,9 @@ func (c *Client) FetchServersFromURL(ctx context.Context, fullURL string) (ListR
|
||||
if err != nil {
|
||||
return ListResponse{}, apperrors.NewDiscovery("failed to create servers request")
|
||||
}
|
||||
for k, v := range c.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return ListResponse{}, apperrors.NewDiscovery(fmt.Sprintf("servers request failed: %v", err))
|
||||
@@ -289,6 +506,9 @@ func (c *Client) fetchServersPage(ctx context.Context, limit int, cursor string)
|
||||
if err != nil {
|
||||
return ListResponse{}, apperrors.NewDiscovery("failed to create market servers request")
|
||||
}
|
||||
for k, v := range c.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return ListResponse{}, apperrors.NewDiscovery(fmt.Sprintf("market servers request failed: %v", err))
|
||||
@@ -427,18 +647,24 @@ func NormalizeServers(response ListResponse, source string) []ServerDescriptor {
|
||||
descriptor.UpdatedAt = updatedAt
|
||||
}
|
||||
|
||||
existing, exists := bestByEndpoint[descriptor.Key]
|
||||
// Dedup key includes cli.id when present so that envelopes
|
||||
// intentionally splitting one MCP endpoint into multiple CLI command
|
||||
// trees (e.g. bot-root / bot-message / bot-group all serving
|
||||
// .../server/4717... but exposing different command roots) are not
|
||||
// collapsed by endpoint-only dedup. Without cli.id the key falls
|
||||
// back to descriptor.Key (= endpoint) for backwards compatibility.
|
||||
endpointKey := dedupKeyForEndpoint(descriptor)
|
||||
existing, exists := bestByEndpoint[endpointKey]
|
||||
if !exists || descriptorIsNewer(descriptor, existing) {
|
||||
bestByEndpoint[descriptor.Key] = descriptor
|
||||
bestByEndpoint[endpointKey] = descriptor
|
||||
}
|
||||
}
|
||||
|
||||
bestByName := make(map[string]ServerDescriptor)
|
||||
for _, descriptor := range bestByEndpoint {
|
||||
nameKey := normalizeDisplayNameKey(descriptor.DisplayName)
|
||||
if nameKey == "" {
|
||||
nameKey = descriptor.Key
|
||||
}
|
||||
// Same reasoning as endpoint dedup: append cli.id so three bot-*
|
||||
// entries with displayName "机器人消息" don't collapse into one.
|
||||
nameKey := dedupKeyForName(descriptor)
|
||||
existing, exists := bestByName[nameKey]
|
||||
if !exists || descriptorIsNewer(descriptor, existing) {
|
||||
bestByName[nameKey] = descriptor
|
||||
@@ -484,6 +710,33 @@ func normalizeDisplayNameKey(displayName string) string {
|
||||
return strings.ToLower(strings.TrimSpace(displayName))
|
||||
}
|
||||
|
||||
// dedupKeyForEndpoint returns the dedup key used when collapsing multiple
|
||||
// envelope entries that share an MCP endpoint. cli.id is appended so an
|
||||
// endpoint intentionally fronting multiple CLI command roots (bot-root /
|
||||
// bot-message / bot-group all served by the same MCP server) stays as
|
||||
// distinct descriptors. When cli.id is empty (or absent), the key is the
|
||||
// endpoint alone to preserve historical dedup behaviour.
|
||||
func dedupKeyForEndpoint(descriptor ServerDescriptor) string {
|
||||
if cliID := strings.TrimSpace(descriptor.CLI.ID); cliID != "" {
|
||||
return descriptor.Key + "#" + cliID
|
||||
}
|
||||
return descriptor.Key
|
||||
}
|
||||
|
||||
// dedupKeyForName mirrors dedupKeyForEndpoint for the second-pass name-based
|
||||
// dedup so two envelopes with the same displayName but distinct cli.id (the
|
||||
// bot-* trio shares displayName "机器人消息") remain separate.
|
||||
func dedupKeyForName(descriptor ServerDescriptor) string {
|
||||
nameKey := normalizeDisplayNameKey(descriptor.DisplayName)
|
||||
if nameKey == "" {
|
||||
nameKey = descriptor.Key
|
||||
}
|
||||
if cliID := strings.TrimSpace(descriptor.CLI.ID); cliID != "" {
|
||||
return nameKey + "#" + cliID
|
||||
}
|
||||
return nameKey
|
||||
}
|
||||
|
||||
func markDeprecatedCandidate(displayName string, lifecycle LifecycleInfo) LifecycleInfo {
|
||||
if lifecycle.DeprecatedCandidate {
|
||||
return lifecycle
|
||||
|
||||
@@ -101,6 +101,65 @@ func TestNormalizeServersDeduplicatesSameNameAcrossEndpoints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeServersPreservesDistinctCLIIDsOnSharedEndpoint guards the
|
||||
// bot-root / bot-message / bot-group split (issue: chat bot subtree vanished
|
||||
// from the CLI after NormalizeServers collapsed three envelopes that share a
|
||||
// single MCP endpoint and displayName but expose different cli.id values).
|
||||
func TestNormalizeServersPreservesDistinctCLIIDsOnSharedEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sharedURL := "https://example.com/server/4717"
|
||||
sharedName := "机器人消息"
|
||||
response := ListResponse{
|
||||
Servers: []ServerEnvelope{
|
||||
{
|
||||
Server: RegistryServer{
|
||||
Name: sharedName,
|
||||
Remotes: []RegistryRemote{{Type: "streamable-http", URL: sharedURL}},
|
||||
},
|
||||
Meta: EnvelopeMeta{
|
||||
Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-29T00:00:00Z"},
|
||||
CLI: CLIOverlay{ID: "bot-root", Command: "bot"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Server: RegistryServer{
|
||||
Name: sharedName,
|
||||
Remotes: []RegistryRemote{{Type: "streamable-http", URL: sharedURL}},
|
||||
},
|
||||
Meta: EnvelopeMeta{
|
||||
Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-29T00:00:00Z"},
|
||||
CLI: CLIOverlay{ID: "bot-message", Command: "message"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Server: RegistryServer{
|
||||
Name: sharedName,
|
||||
Remotes: []RegistryRemote{{Type: "streamable-http", URL: sharedURL}},
|
||||
},
|
||||
Meta: EnvelopeMeta{
|
||||
Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-29T00:00:00Z"},
|
||||
CLI: CLIOverlay{ID: "bot-group", Command: "group"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
servers := NormalizeServers(response, "live_market")
|
||||
if len(servers) != 3 {
|
||||
t.Fatalf("NormalizeServers() len = %d, want 3 (one per cli.id)", len(servers))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, s := range servers {
|
||||
seen[s.CLI.ID] = true
|
||||
}
|
||||
for _, want := range []string{"bot-root", "bot-message", "bot-group"} {
|
||||
if !seen[want] {
|
||||
t.Errorf("NormalizeServers() missing descriptor with cli.id %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeServersMarksLegacyNameAsDeprecatedCandidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user