Compare commits

...
18 Commits
Author SHA1 Message Date
修雨 91f44a1efd docs: cut 1.0.39 changelog (#475 wukong-leak fix + #477 ai-tag opt-in) (#478) 2026-06-18 14:10:07 +08:00
修雨 1a7ba01e36 feat(chat): make AI-sent badge opt-in via --ai-tag (default off) (#477)
Previously every user-identity send/reply attached clawType=edition.ClawType()
unconditionally, so the IM server rendered an AI-sent badge ("通过AI发送" on the
open edition) under every message — surprising users by branding all their sends.

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

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

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

Bot sends intentionally stay untouched: they already render as bot
messages and must not carry the user-identity claw tag.
2026-06-18 10:53:52 +08:00
修雨 f1a68f2424 docs: cut 1.0.38 changelog (#469) 2026-06-16 20:20:20 +08:00
patrickmen 497e4f87d8 feat: support to disable auto open browser (#365) 2026-06-16 20:08:29 +08:00
Jackjin a8d009aec8 fix(upgrade): honor --dry-run as preview-only instead of silently upgrading (#416)
dws upgrade registered no --dry-run flag and never read the global one,
so --dry-run fell through to runUpgrade and performed a real, irreversible
upgrade (download + replace binary). This contradicts the flag's documented
contract (预览操作内容,不实际执行).

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

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

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

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

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

Includes unit tests for every tier and the integration doc.
2026-06-16 17:55:29 +08:00
修雨 ff33114b2c feat(doc): strip server-rejected unsafe chars on markdown write path (#465)
The doc write boundary only stripped a fixed dangerous-Unicode set, and only
on the JSONML path. C0 control characters (except tab/newline), DEL (0x7F),
and a few zero-width / line-separator codepoints still reached the server,
where RejectControlChars rejects them — so doc create/update failed on content
that pasted in such characters (common with LLM-generated or copy-pasted text).

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

Ported from dws-wukong (feat: 增加输入安全字符过滤功能).
2026-06-16 10:56:53 +08:00
修雨 cdd8414891 docs: cut 1.0.37 changelog (#457) 2026-06-11 19:53:26 +08:00
xuanandshangguanxuan.sgx 2a82d07311 fix(pat): support batch agentCode and guarded grants (#455)
* fix(pat): carry agentCode in batch auth args

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

* fix(pat): require yes for batch grants

* test(pat): cover cli authorization matrix

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

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-06-11 19:21:25 +08:00
修雨 60ac0b409d fix(cli): guard canonical mcp tree against poisoned-cache flag collisions (#454)
* fix(cli): guard canonical mcp tree against poisoned-cache flag collisions

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

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

* docs: amend 1.0.36 changelog for the re-cut with the canonical tree guard (#454)
2026-06-11 09:16:36 +08:00
修雨 4bc4b60dca docs: cut 1.0.36 changelog (#453) 2026-06-10 22:45:50 +08:00
修雨 31e65dda51 fix(cli): self-heal a poisoned discovery cache by quarantining it and rebuilding (#452)
A panic during the envelope-driven command build no longer just degrades
to helper commands (#447): the partition's discovery cache is first moved
aside to <partition>.quarantined (kept for inspection, previous quarantine
replaced) and the build retried once against a fresh fetch. Any path that
delivers a fixed binary -- dws upgrade or a reinstall -- now escapes the
lock-out with zero manual cache surgery; only a second panic (remote
envelope still poisoned, or offline) falls back to the degraded helper
set with the 'dws cache refresh' hint.

dws upgrade additionally clears the discovery-derived caches (market /
tools / detail across all partitions, leaving the downloads dir alone)
after the binary swap, so the upgraded binary rebuilds its command tree
from fresh data instead of inheriting snapshots written by the old
version.
2026-06-10 22:45:35 +08:00
修雨 387ae5ff59 fix(doc): strip leading H1 duplicating --name on doc create (#448)
* fix(doc): strip leading H1 duplicating --name on doc create

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

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

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

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

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

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

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

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

Wrap the envelope-driven build in a local recover: on panic the CLI now
logs the failure, prints a stderr hint pointing at 'dws cache refresh',
and falls back to the hardcoded helper commands so utility commands
stay alive and users can self-heal.
2026-06-10 15:28:58 +08:00
xuanandshangguanxuan.sgx eaa60f95b5 feat(devdoc): add rag mcp cli commands (#434)
* feat(devdoc): add rag mcp cli commands

* chore(config): default mcp endpoint to prepub

* chore(config): use prepub mcp discovery host

* fix(devdoc): wrap rag search request

* fix(transport): preserve dingtalk mcp gateway query

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

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

* test(devdoc): cover rag cli parameter mapping

* chore(test): remove dead nested arg assertion

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-06-09 18:00:05 +08:00
67 changed files with 4133 additions and 185 deletions
+53
View File
@@ -6,6 +6,59 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.39] - 2026-06-18
This release makes the AI-sent indicator opt-in. 1.0.38 unconditionally tagged every user-identity send/reply with the edition claw identity, so the IM server rendered a "Send from AI" badge under every message — and on the open edition a stale hardcoded value even leaked the Wukong-branded label (「悟空AI发送」) to external users. The badge is now off by default and shown only when the caller explicitly asks for it.
### Added
- **`--ai-tag` opt-in flag for `chat message send` / `chat message reply`** (#477; `internal/helpers/chat.go`) — by default no `clawType` tool argument is attached, so delivered messages carry no "Send from AI" badge. Passing `--ai-tag` attaches `edition.ClawType()` so the IM server renders the badge (open edition `openClaw` → 「通过AI发送」; the wukong overlay sets its own value → 「悟空AI发送」). Covers the text/Markdown, rich-media, and `--user`/`--open-dingtalk-id` direct send paths plus `reply`. Bot (`send-by-bot`) and webhook sends are intentionally untouched — they already render as bot messages. The badge is opt-in so dws does not brand every message a user sends.
### Fixed
- **`dws chat message reply` no longer leaks the Wukong AI label on the open edition** (#475, fixes #474; `internal/helpers/chat.go`, `pkg/edition/edition.go`) — the reply path hardcoded `clawType: "wukong"`, so open-source quoted replies were tagged 「悟空AI发送」 by the IM server, leaking Wukong branding to external users (reported by an external customer integrating via openclaw). The value now derives from the edition via the new `edition.ClawType()` accessor (open → `DefaultOSSClawType` = `openClaw`), and — together with #477 — is only attached when `--ai-tag` is passed. The earlier fix existed on a branch (PR #450) but was never merged to main; #475 cherry-picked it.
## [1.0.38] - 2026-06-16
This release adds client-side agent attribution for usage stats, fixes two commands that silently misbehaved (`dws sheet export` hanging, `dws upgrade --dry-run` actually upgrading), hardens the document write path against server-rejected characters, and makes the long-broken `--no-browser` login flag actually work.
### Added
- **Client-side `agent_code` detection + per-channel agent instance id for usage stats** (#467; `internal/auth/agent_code_detect.go`, `internal/auth/identity.go`, `docs/agent-code.md`) — every MCP request now carries `x-dingtalk-dws-agent-code` (which agent host is driving dws — e.g. `claudecode` / `codex` / `qoder` / `cursor` / `hermes` / `openclaw`, falling back to `custom`), `x-dws-agent-instance-id` (a per-machine×channel id, `dwsa_<base62(sha256(machineId|agent_code))>`), the existing machine-level `x-dws-agent-id`, and `X-Cli-Version`. Detection is a confidence ladder, each signature verified on real hosts / official docs (never guessed; anything unrecognized resolves to `custom`): T0 explicit `DINGTALK_DWS_AGENTCODE`, T1 per-agent env signatures, T2 `VSCODE_BRAND` covering the whole VS Code fork family, T3 the macOS `__CFBundleIdentifier` map, T4 `custom`. `identity.json` migrates v1 → v2 transparently and keeps `x-dws-agent-id` machine-level for continuity. **Trust boundary:** `agent_code` and both ids are client self-reported and forgeable — they are for stats / observability only and must not be used for auth, authorization, rate-limiting, billing, or revocation. Server-side gateway work (header passthrough allowlist + logging the fields into the warehouse) is required before the data lands and is tracked separately.
### Fixed
- **`dws sheet export` no longer hangs for the full ~5-minute poll timeout** (#462; `internal/compat/pipeline.go`) — the pipeline poll loop compared the API status against `pollUntilValue` with case-sensitive `==`, but the API returns `"success"` while the pipeline config declares `"SUCCESS"`, so the match never fired and the loop spun until timeout. Switched to `strings.EqualFold`, aligning with the case-insensitive `normalizeAsyncStatus` helper already used for `doc export` / `aitable export`.
- **`dws upgrade --dry-run` now previews instead of performing a real upgrade** (#416, fixes #364; `internal/app/upgrade.go`) — `newUpgradeCommand` registered no `--dry-run` flag and never read the global persistent one, so `--dry-run` fell through and ran a real, irreversible upgrade (download + binary replace), directly contradicting the flag's documented `预览操作内容,不实际执行` contract. It now resolves the target release and platform asset (so "already latest" / "no build for this platform" is still surfaced), prints the 1–5 steps it *would* perform via the side-effect-free `writeDryRunPlan`, and returns before any backup / download / replace. Covered by `TestWriteDryRunPlan_*` and an updated help test.
- **`dws doc create` / `dws doc update` strip server-rejected characters instead of failing** (#465; `internal/helpers/doc.go`, `internal/helpers/doc_jsonml.go`) — the Markdown write path sent raw content straight through, and the dangerous-Unicode strip only ran on the JSONML branch, so content carrying C0 control characters (anything `< 0x20` except `\t` / `\n`), DEL (`0x7F`), or zero-width / line-separator codepoints (`U+200D`, `U+2028`, `U+2029`) — common in LLM-generated or copy-pasted text — was rejected by the server-side `RejectControlChars` validator and the command failed. `stripDocDangerousUnicode` is renamed to `stripDocInputUnsafe`, extended to match the authoritative `apiclient.rejectDangerousChars` set, and applied on both the Markdown and JSONML node write paths. Tab and newline are preserved. Ported from dws-wukong.
- **`dws auth login --no-browser` is now honored** (#365; `internal/app/auth_command.go`, `internal/auth/device_flow.go`, `internal/auth/oauth_provider.go`) — the flag was already defined (and hidden) but never wired to the login providers, so the browser always opened regardless. The value is now passed into `DeviceFlowProvider.NoBrowser` / `OAuthProvider.NoBrowser` and gates the `openBrowser` call; the flag is also unhidden so headless / remote sessions can discover it.
## [1.0.37] - 2026-06-11
This release realigns the npm channel and hardens PAT batch grants. Background on the npm realignment: 1.0.36 was re-cut on GitHub on 2026-06-11 to fold in the canonical-tree poisoned-cache guard (#454), but the npm registry permanently forbids republishing a version number, so the npm package stayed on the original, unguarded cut. 1.0.37 is therefore the first version where **every** distribution channel — GitHub releases, `dws upgrade`, the install scripts, and npm — ships the same guarded build. If you installed 1.0.36 from npm, upgrade to this version.
### Fixed
- **PAT batch grants carry the agent identity and require explicit confirmation** (#455; `internal/pat/chmod.go`, `internal/auth/channel.go`, `internal/app/runner.go`) — an explicit `--agentCode` flag or the `DINGTALK_DWS_AGENTCODE` env var is now carried into PAT batch plan/grant arguments instead of being dropped, and a missing agentCode is forwarded as absent so the PAT core can apply the server-side default rather than failing. Batch grants now refuse to execute without an explicit `--yes` (dry-run and single-scope grants keep their existing behavior), closing the gap where a multi-scope grant could fire without a deliberate confirmation. Only the canonical env name `DINGTALK_DWS_AGENTCODE` is recognized; draft/reversed spellings from earlier iterations are ignored. Verified against prepub: dry-run, single grant, flag-priority grant, and batch grant all resolve the target agentCode, with the granted rows confirmed server-side. Tests: `internal/pat/chmod_test.go`, `internal/pat/browser_policy_test.go`, `test/unit/pat_host_owned_signal_test.go`.
## [1.0.36] - 2026-06-10
This release closes out the poisoned-discovery-cache lock-out for good, with four layers of defense landing together. The lock-out class (seen again on 2026-06-09 as `chat_permission_grant flag redefined: params`): the dynamic command tree is built from cached discovery data **before** Cobra dispatches any command, so a pflag panic fed by a poisoned cache aborted *every* invocation — including `dws cache refresh` and `dws upgrade`, the very commands that could repair it. Now: (1) any panic during the build is recovered instead of crashing (#447), (2) the four known envelope shapes that made pflag panic are skipped at registration so they never fire (#449), (3) when an unknown panic class does fire, the CLI quarantines the poisoned cache and rebuilds itself from a fresh fetch — and `dws upgrade` clears the discovery caches after every binary swap, so simply getting this version onto a machine is enough to escape, no manual cache surgery (#452), and (4) the same guards now also cover the canonical `dws mcp` tree, which is built even earlier and sat outside all three defenses as originally cut (#454 — this release was re-cut on 2026-06-11 to include it; verified against the preserved real poisoned cache from the 2026-05-25 incident). Also in this release: `dws devdoc` gains RAG-backed Open Platform doc search and a new error-diagnosis command (#434), and `dws doc create` stops producing documents with two identical titles (#448).
**Escaping a locked-out older binary**: a binary ≤1.0.35 bricked by a poisoned cache cannot run `dws upgrade`. Either bypass the cache for one invocation with `DWS_CACHE_DIR=$(mktemp -d) dws upgrade`, or delete `~/.dws/cache/<partition>/tools/` by hand, or reinstall via the install script. Once 1.0.36 is on the machine this never needs doing again.
### Added
- **`dws devdoc` — RAG-backed Open Platform doc search and error diagnosis** (#434; `internal/helpers/devdoc.go`, `internal/transport/client.go`) — `dws devdoc article search` now routes to the upstream `search_open_platform_docs_rag` tool, returning structured RAG/reference payloads (the CLI stays a thin invoker; no extra AI analysis layer). New `dws devdoc error diagnose` (alias `troubleshoot`) routes to `search_open_error_code_rag` for diagnosing DingTalk Open Platform API errors, with `--request-id` (hidden `--trace-id` kept for compatibility), `--error-code`, `--error-message`, `--api`, `--context`, `--query`, `--page`, `--size`. Transport-side: query parameters required by DingTalk MCP gateway URLs are preserved on the wire but their values are redacted from debug logs. Default MCP / skill hosts stay on production `https://mcp.dingtalk.com` (prepub remains runtime-configurable). Skill docs (mono + multi `dingtalk-devdoc`) and `docs/command-index.md` updated alongside.
### Fixed
- **CLI no longer bricks when the dynamic command build panics — degrades to built-in commands** (#447; `internal/app/legacy.go`) — `buildEnvelopeCommandsSafe` wraps the envelope-driven build in a local `recover()`. On panic the CLI logs it, prints a stderr hint, and falls back to the hardcoded helper commands, so `auth` / `cache` / `doctor` / `version` / `upgrade` and the helpers stay alive and `dws cache refresh` can rebuild the poisoned cache. Before this, the only recovery from the pre-1.0.32 lock-out class was manually deleting cache files; the duplicate-flag class itself had been fixed at the builder level, but any *future* panic class in the cache-driven build would have bricked the CLI again. Tests: `TestNewLegacyPublicCommandsPanicFallsBackToHelpers`, `TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath`.
- **Envelope-driven flag registration no longer panics on the four known malformed-envelope shapes** (#449; `internal/compat/registry.go`) — while reproducing the lock-out byte-for-byte, four envelope shapes were found still forwarded to pflag calls that panic, each bricking every invocation: a flag named `params` / `json` colliding with the reserved payload flags (the original `flag redefined: params` — earlier dedup fixes covered the alias list and Detail-schema path but not the primary name); two bindings resolving to the same long flag name across bindings; two flags claiming the same shorthand; and a multi-character shorthand. Two small guards applied at every registration site (`ApplyBindings`, `registerPositionalAliasFlags`): `canRegisterFlag` skips duplicate/reserved long names (the value stays reachable via `--params`), and `safeShorthand` drops an invalid or already-taken shorthand while keeping the long flag. The trailing `--json` / `--params` registration is now idempotent. Defense in depth with #447: the escape hatch should never trigger for these known vectors. Test: `TestBuildDynamicCommandsSurvivesMalformedFlagEnvelope` (5 table-driven vectors).
- **Poisoned discovery cache now self-heals: quarantine + rebuild on panic, and `dws upgrade` clears discovery caches** (#452; `internal/app/legacy.go`, `internal/app/upgrade.go`, `internal/cache/store.go`) — #447's recovery is upgraded from "degrade and ask the user to run `dws cache refresh`" to a two-stage self-heal: on the first build panic the partition's discovery cache is moved aside to `<partition>.quarantined` (kept on disk for inspection; a previous quarantine is replaced so nothing accumulates — new `Store.QuarantinePartition`) and the build retried once against a fresh fetch. If the retry succeeds the user gets the full dynamic command tree with zero manual steps; only a second panic (remote envelope itself still poisoned, or offline) degrades to helper commands with the `cache refresh` hint. Additionally `dws upgrade` purges discovery-derived caches (`market` / `tools` / `detail` across all partitions — new `Store.PurgeDiscoveryData`) after a successful binary swap, leaving the co-located `downloads/` cache untouched, so an upgraded binary always rebuilds its command tree from fresh data instead of inheriting snapshots written by the old version. Tests: `internal/cache/store_quarantine_test.go`, rewritten `internal/app/legacy_panic_fallback_test.go` (self-heal success, double-panic degradation, no-cache no-op, happy path).
- **Canonical `dws mcp` tree no longer escapes the poisoned-cache guards** (#454; `internal/cli/canonical.go`, `internal/app/root.go`) — the canonical tree is assembled from cached catalog data *before* the legacy command build, so a pflag panic there — a tool schema property named after the reserved `--params` flag, exactly what the 2026-05-25 incident cache contained — bypassed #447/#449/#452 entirely and still bricked every invocation, including on this release as originally cut. Two layers, mirroring the existing guards: `applyFlagSpecs` skips reserved (`--json`/`--params`), duplicate, and alias-colliding flag names and sanitizes shorthands (`canRegisterToolFlag` / `safeToolShorthand`; a skipped property stays reachable through the reserved JSON payload flags), and `newMCPCommand` wraps the build in the #452 recover → quarantine → retry-once → degrade-to-stub sequence. Verified against the preserved real poisoned cache: the original cut locks out on `--version` / `cache refresh` / `doctor`; this build self-heals on first run and `cache refresh` clears the poison. Tests: `internal/cli/canonical_flag_guard_test.go` (4 cases), `internal/app/canonical_panic_fallback_test.go` (4 cases mirroring the legacy fallback suite).
- **`dws doc create` no longer produces a document with two identical headings** (#448; `internal/helpers/doc.go`) — the platform renders the document name as the page title, and LLM agents habitually repeat `# <title>` as the markdown body's first line despite the skill docs saying not to, so duplicate-heading documents kept appearing. The `doc create` helper (which wins the envelope merge via `preferLegacyLeaf`) now strips a leading ATX H1 whose text exactly equals `--name` (trimmed, case-insensitive) before forwarding to `create_document`, printing a stderr note so agents learn the convention. Deliberately conservative: only an exact match is removed (`# 背景` stays), ATX closing hashes are handled without over-trimming names ending in `#` (e.g. `C#`), H2+/setext headings are never touched, and a body that is nothing but the duplicate H1 omits the `markdown` param instead of sending an empty string. JSONML bodies are out of scope. Tests: `TestStripLeadingDuplicateTitleHeading` (9 cases) plus three end-to-end cobra tests asserting the exact `markdown` param sent.
## [1.0.35] - 2026-06-08
### Fixed
+2 -2
View File
@@ -492,12 +492,12 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| Mail | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | List mailboxes, KQL message search, read & send messages, drafts, folders, tags, threads, attachments, address-book user search |
| Sheet | `sheet` | 23 | `range` `filter-view` (top-level: `create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read / write / append, dimension ops, cell merge / unmerge, find / replace, named filter views + sheet-level filters, image write |
| Wiki | `wiki` | 21 | `space` `member` `node` `doc` `file` | Knowledge base management: spaces (`create` / `get` / `list` / `search`), members (`add` / `list` / `update`), node tree, docs & files |
| DevDoc | `devdoc` | 1 | `article` | Search the DingTalk Open Platform documentation |
| DevDoc | `devdoc` | 2 | `article` `error` | Search Open Platform documentation and troubleshoot API errors |
| AI Search | `aisearch` | 3 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
> **330 commands across 18 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
> **331 commands across 18 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
+2 -2
View File
@@ -488,12 +488,12 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| 邮箱 | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | 邮箱地址列表、KQL 邮件搜索、读取与发送邮件、草稿、文件夹、标签、会话、附件、通讯录用户搜索 |
| 在线电子表格 | `sheet` | 23 | `range` `filter-view`(顶层:`create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并/取消合并、查找替换、命名筛选视图 + 表级筛选、写入图片 |
| 知识库 | `wiki` | 21 | `space` `member` `node` `doc` `file` | 知识库管理:空间(`create` / `get` / `list` / `search`)、成员(`add` / `list` / `update`)、节点树、文档与文件 |
| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 |
| 开发者文档 | `devdoc` | 2 | `article` `error` | 搜索钉钉开放平台文档、排查开放平台调用错误 |
| AI 搜问 | `aisearch` | 3 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
> **18 个产品,330 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
> **18 个产品,331 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
+93
View File
@@ -0,0 +1,93 @@
# Agent identification (agent_code & agentId)
dws tags every MCP request with **which agent host is driving it** and a
**per-instance id**, so usage can be sliced by channel/instance in the data
warehouse. This page is the integration contract.
## What dws sends on the wire
| Header | Meaning | Granularity |
|--------|---------|-------------|
| `x-dingtalk-dws-agent-code` | which agent host (claudecode / codex / qoder / cursor / custom …) | channel |
| `x-dws-agent-instance-id` | `dwsa_<base62>` derived from `machineId + agent_code` | machine × channel |
| `x-dws-agent-id` | stable per-install machine id (v1-compatible) | machine |
| `X-Cli-Version` | dws CLI version (segments old vs new clients) | — |
`x-dws-agent-id` keeps its original machine-level meaning for backward
compatibility; `x-dws-agent-instance-id` is the new per-channel value. Old
clients send no `agent_code` / instance id — treat their absence as
"legacy/unknown", not an error.
## How `agent_code` is resolved (confidence ladder)
1. **T0 — explicit declaration:** `DINGTALK_DWS_AGENTCODE=<code>`. **Use this.**
2. **T1 — verified env signature:** an agent that auto-sets a distinctive var
(`CLAUDECODE`, `CODEX_SANDBOX`, `OPENCLAW_BUNDLE_ROOT`, `HERMES_HOME`).
3. **T2 — `VSCODE_BRAND`:** every VS Code fork declares its brand — one rule
covers Cursor / Windsurf / Trae / Qoder / Kiro / … incl. future forks.
4. **T3 — macOS `__CFBundleIdentifier`:** known agent app bundles.
5. **T4 — `custom`:** unknown host. Never guessed.
## Declaring your agent (recommended — the only fully-general path)
Auto-detection cannot cover every agent: most terminal agents (gemini/
antigravity, aider, opencode, qwen-code, crush, goose, kimi, amazon-q,
continue, …) expose **no reliable self-identifying env var** — only user-set
API keys, which must not be used as identity. The robust answer is: **the host
sets `DINGTALK_DWS_AGENTCODE` in the env block where it launches dws as an MCP
server.** This is accurate for any agent, on any OS, and is future-proof.
MCP server config example (JSON-style hosts):
```jsonc
{
"mcpServers": {
"dingtalk-workspace": {
"command": "dws",
"args": ["mcp", "..."],
"env": { "DINGTALK_DWS_AGENTCODE": "your-agent-code" }
}
}
}
```
### Canonical codes
`claudecode`, `codex`, `cursor`, `vscode`, `qoder`, `windsurf`, `trae`,
`workbuddy`, `openclaw`, `hermes`, `codebuddy`, `comate`, `lingma`, `gemini`,
`aider`, `opencode`, `goose`, `crush`, `kimi`, `amazonq`, `continue`, …
Use a stable lowercase slug; unknown values are kept as-is (lowercased,
spaces stripped), so a new agent name flows through cleanly.
## Trust & limitations — READ THIS
**`agent_code` AND the ids (`x-dws-agent-id`, `x-dws-agent-instance-id`) are
self-reported, best-effort signals, NOT an authenticated identity.**
- `agent_code`: every declaration/auto-detect signal is an env var the
host/user controls — spoofable (`export CLAUDECODE=1` → dws reports
`claudecode`).
- The ids are **even easier to forge**: they are generated, stored, and sent
entirely client-side. `machineId` is a random UUID in the plaintext
`~/.dws/identity.json` (which the user owns), and the instance id is just
`sha256(machineId + agent_code)`. Editing that one file — or rewriting the
header — lets anyone mint, split, rotate, or impersonate ids at will. The
`dwsa_` prefix does NOT make it a secure identifier.
- ✅ **Fit for statistics / observability** (the intended use): there is no
incentive to misreport one's own agent, and real hosts emit real signals, so
aggregate per-channel metrics are reliable in practice.
- ❌ **NOT fit for authentication, authorization, rate-limiting, billing, or
revocation.** Anything where a party benefits from lying must not trust this
field. For control-plane use you need a gateway-issued **authoritative**
agentId bound to a verified credential (clientId / PAT / OAuth) — a separate,
heavier mechanism, deliberately out of scope here.
Treat `agent_code` / `x-dws-agent-instance-id` as analytics dimensions only.
## Gateway side (required for the data to land)
dws sending the headers is necessary but not sufficient. The gateway must:
1. add `x-dingtalk-dws-agent-code`, `x-dws-agent-instance-id`, `X-Cli-Version`
to the upstream-header pass-through allowlist (otherwise they are stripped);
2. log them as fields, and deliver them to the warehouse (alongside the
existing flow-control / execution logs).
+4 -4
View File
@@ -4,7 +4,7 @@ Every runtime command the `dws` CLI exposes when loaded with the **pre** environ
- **Source**: `dws-wukong/envelope/channel/open/pre/config.json`
- **Products**: 13
- **Total commands**: 159
- **Total commands**: 160
- **Generated from**: `internal/compat.BuildDynamicCommands` rendering of the pre config — the same code path the CLI uses at runtime.
> Auto-generated. Edit `pre/config.json`, not this file.
@@ -36,7 +36,7 @@ Every command inherits these flags (documented here once, not repeated per comma
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 1 commands
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
- [`dws doc` — DingTalk Doc](#dws-doc) · 21 commands
- [`dws drive` — DingTalk Drive](#dws-drive) · 6 commands
@@ -182,11 +182,12 @@ _Users, departments, and directory lookups._
_Search the DingTalk Open Platform documentation._
**1 commands**
**2 commands**
| Command | Description | When to use |
|---|---|---|
| `dws devdoc article search` | Search the DingTalk Open Platform documentation by keyword. | When the agent needs authoritative API reference or guides to answer a developer question. |
| `dws devdoc error diagnose` | Troubleshoot an Open Platform API failure by requestId, error code, error message, or context. | When the agent has a requestId, traceId, error code, or failure description and needs diagnostic facts plus references. |
## `dws ding` — DING Messages
@@ -320,4 +321,3 @@ _Personal todo task management._
| `dws todo task get` | Retrieve the full details of a todo item by ID. | When the agent inspects a specific todo's content, due date, and executors. |
| `dws todo task list` | List todos for the current user within the current organization. | When the agent surfaces the user's outstanding tasks or builds a daily focus list. |
| `dws todo task update` | Update a todo's title, description, due time, or executors. | When the agent edits an existing todo after new information comes in. |
+2 -1
View File
@@ -116,6 +116,7 @@ func newAuthLoginCommand() *cobra.Command {
provider := authpkg.NewDeviceFlowProvider(configDir, nil)
provider.Output = cmd.ErrOrStderr()
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
tokenData, err = provider.Login(loginCtx)
if err != nil {
return apperrors.NewAuth(fmt.Sprintf("device authorization failed: %v", err))
@@ -126,6 +127,7 @@ func newAuthLoginCommand() *cobra.Command {
provider := authpkg.NewOAuthProvider(configDir, nil)
provider.Output = cmd.ErrOrStderr()
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
configureOAuthProviderCompatibility(provider, configDir)
tokenData, err = provider.Login(loginCtx, cfg.Force)
if err != nil {
@@ -186,7 +188,6 @@ func newAuthLoginCommand() *cobra.Command {
_ = cmd.Flags().MarkHidden("token-url")
_ = cmd.Flags().MarkHidden("refresh-url")
_ = cmd.Flags().MarkHidden("login-timeout")
_ = cmd.Flags().MarkHidden("no-browser")
return cmd
}
@@ -0,0 +1,157 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/spf13/cobra"
)
// TestNewMCPCommandPanicDegradesToStub verifies the canonical-tree guard:
// the `dws mcp` build runs BEFORE the legacy build and used to sit outside
// every poisoned-cache guard, so a panic there (e.g. a tool schema property
// named after the reserved --params flag) aborted every invocation. With no
// on-disk cache to quarantine it must degrade to an inert stub instead.
func TestNewMCPCommandPanicDegradesToStub(t *testing.T) {
t.Setenv(cli.CacheDirEnv, t.TempDir())
calls := 0
orig := buildMCPCommandFn
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
calls++
panic("chat_permission_grant flag redefined: params")
}
t.Cleanup(func() { buildMCPCommandFn = orig })
var cmd *cobra.Command
captured := captureStderr(t, func() {
cmd = newMCPCommand(context.Background(), nil, nil, nil)
})
if cmd == nil || cmd.Name() != "mcp" {
t.Fatalf("newMCPCommand() = %v after build panic, want an 'mcp' stub", cmd)
}
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "dws cache refresh") {
t.Errorf("stub RunE error = %v, want a 'dws cache refresh' hint", err)
}
if !strings.Contains(captured, "dws cache refresh") {
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
}
if calls != 1 {
t.Errorf("canonical build attempts = %d, want 1 (no cache on disk, nothing to quarantine and retry)", calls)
}
}
// TestNewMCPCommandSelfHealsPoisonedCache verifies the self-heal path: when
// the build panics AND a discovery cache exists on disk, the partition is
// quarantined and the build retried once, so a fixed binary escapes the
// lock-out with zero manual cache surgery.
func TestNewMCPCommandSelfHealsPoisonedCache(t *testing.T) {
tmp := t.TempDir()
t.Setenv(cli.CacheDirEnv, tmp)
store := cache.NewStore(tmp)
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
calls := 0
orig := buildMCPCommandFn
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
calls++
if calls == 1 {
panic("chat_permission_grant flag redefined: params")
}
return &cobra.Command{Use: "mcp", Short: "rebuilt-probe"}
}
t.Cleanup(func() { buildMCPCommandFn = orig })
var cmd *cobra.Command
captured := captureStderr(t, func() {
cmd = newMCPCommand(context.Background(), nil, nil, nil)
})
if calls != 2 {
t.Fatalf("canonical build attempts = %d, want 2 (initial + retry after quarantine)", calls)
}
if cmd == nil || cmd.Short != "rebuilt-probe" {
t.Errorf("newMCPCommand() did not return the rebuilt tree, got %v", cmd)
}
quarantines, _ := filepath.Glob(filepath.Join(tmp, "*.quarantined"))
if len(quarantines) != 1 {
t.Fatalf("quarantine dirs = %v, want exactly 1", quarantines)
}
if !strings.Contains(captured, "rebuilding from a fresh fetch") {
t.Errorf("stderr = %q, want a note about rebuilding from a fresh fetch", captured)
}
}
// TestNewMCPCommandSecondPanicDegradesToStub verifies the final safety net:
// if the rebuild after quarantine panics again, the stub is returned and the
// `dws cache refresh` hint kept.
func TestNewMCPCommandSecondPanicDegradesToStub(t *testing.T) {
tmp := t.TempDir()
t.Setenv(cli.CacheDirEnv, tmp)
store := cache.NewStore(tmp)
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
calls := 0
orig := buildMCPCommandFn
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
calls++
panic("chat_permission_grant flag redefined: params")
}
t.Cleanup(func() { buildMCPCommandFn = orig })
var cmd *cobra.Command
captured := captureStderr(t, func() {
cmd = newMCPCommand(context.Background(), nil, nil, nil)
})
if calls != 2 {
t.Fatalf("canonical build attempts = %d, want 2 (initial + retry after quarantine)", calls)
}
if cmd == nil || cmd.Name() != "mcp" {
t.Fatalf("newMCPCommand() = %v after repeated panics, want an 'mcp' stub", cmd)
}
if !strings.Contains(captured, "dws cache refresh") {
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
}
}
// TestNewMCPCommandNoPanicKeepsCanonicalPath ensures the guard is transparent
// on the happy path.
func TestNewMCPCommandNoPanicKeepsCanonicalPath(t *testing.T) {
orig := buildMCPCommandFn
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
return &cobra.Command{Use: "mcp", Short: "canonical-probe"}
}
t.Cleanup(func() { buildMCPCommandFn = orig })
cmd := newMCPCommand(context.Background(), nil, nil, nil)
if cmd == nil || cmd.Short != "canonical-probe" {
t.Errorf("newMCPCommand() lost the canonical command, got %v", cmd)
}
}
+71 -2
View File
@@ -16,6 +16,7 @@ package app
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
@@ -49,7 +50,75 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
return mergeTopLevelCommands(commands)
}
dynamicCmds := loadDynamicCommands(ctx, runner)
return buildEnvelopeCommandsSafe(ctx, runner)
}
// loadDynamicCommandsFn is a test seam for buildEnvelopeCommandsSafe so a
// panic in the cache-driven build can be simulated without crafting a
// poisoned on-disk cache.
var loadDynamicCommandsFn = loadDynamicCommands
// buildEnvelopeCommandsSafe builds the public command set from the discovery
// envelope, self-healing a poisoned cache when the dynamic build panics and
// degrading to the hardcoded helper commands only if that also fails.
//
// Why this guard exists: the dynamic command tree is constructed from cached
// discovery data BEFORE Cobra dispatches any command, so a panic here (e.g.
// a duplicate pflag registration fed by a poisoned cache, as seen before
// 1.0.32: "chat_permission_grant flag redefined: params") used to abort
// every invocation — including `dws cache refresh`, the very command that
// repairs the cache.
//
// Recovery is two-staged. First the partition's discovery cache is moved
// aside (kept on disk for inspection) and the build retried against a fresh
// fetch — so any path that delivers a fixed binary (`dws upgrade`, reinstall)
// escapes the lock-out with zero manual cache surgery. Only when the rebuild
// panics again (e.g. the remote envelope itself is still poisoned, or the
// machine is offline with no usable cache) does the CLI degrade to utility
// and helper commands with a `dws cache refresh` hint.
func buildEnvelopeCommandsSafe(ctx context.Context, runner executor.Runner) []*cobra.Command {
cmds, panicked := tryBuildEnvelopeCommands(ctx, runner)
if panicked == nil {
return cmds
}
slog.Error("buildEnvelopeCommandsSafe: dynamic command build panicked", "panic", panicked)
quarantined, qErr := cacheStoreFromEnv().QuarantinePartition(editionPartition())
if qErr != nil {
slog.Error("buildEnvelopeCommandsSafe: failed to quarantine discovery cache", "error", qErr)
}
if quarantined != "" {
fmt.Fprintf(os.Stderr,
"Warning: building product commands from the local discovery cache failed: %v\n"+
"The cached discovery data was moved to %s; rebuilding from a fresh fetch...\n",
panicked, quarantined)
cmds, panicked = tryBuildEnvelopeCommands(ctx, runner)
if panicked == nil {
fmt.Fprintln(os.Stderr, "Product commands rebuilt successfully.")
return cmds
}
slog.Error("buildEnvelopeCommandsSafe: rebuild after cache quarantine panicked again, degrading to built-in commands", "panic", panicked)
}
fmt.Fprintf(os.Stderr,
"Warning: building product commands from the local discovery cache failed: %v\n"+
"Product commands are temporarily unavailable; utility commands still work.\n"+
"Run 'dws cache refresh' to rebuild the cache.\n", panicked)
return mergeTopLevelCommands(helpers.NewPublicCommands(runner))
}
// tryBuildEnvelopeCommands runs one attempt of the envelope-driven build,
// converting a panic into a return value so the caller can decide between
// self-heal and degradation.
func tryBuildEnvelopeCommands(ctx context.Context, runner executor.Runner) (cmds []*cobra.Command, panicked any) {
defer func() {
if r := recover(); r != nil {
cmds = nil
panicked = r
}
}()
dynamicCmds := loadDynamicCommandsFn(ctx, runner)
helperCmds := helpers.NewPublicCommands(runner)
merged := mergeTopLevelCommands(pickCommands(dynamicCmds, helperCmds))
// Post-merge product hooks: tasks the envelope cannot express on its
@@ -58,7 +127,7 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
// command surface remains predictable from the envelope alone.
helpers.AttachReportLegacyInboxAlias(merged, runner)
helpers.AttachReportListReadableEnrichment(merged, runner)
return merged
return merged, nil
}
// pickCommands returns the union of dynamic and helpers commands. For
+203
View File
@@ -0,0 +1,203 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
// captureStderr redirects os.Stderr for the duration of fn and returns what
// was written to it.
func captureStderr(t *testing.T, fn func()) string {
t.Helper()
pipeR, pipeW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe() error = %v", err)
}
origStderr := os.Stderr
os.Stderr = pipeW
defer func() { os.Stderr = origStderr }()
fn()
_ = pipeW.Close()
os.Stderr = origStderr
captured, _ := io.ReadAll(pipeR)
return string(captured)
}
// TestNewLegacyPublicCommandsPanicFallsBackToHelpers verifies the escape
// hatch for a poisoned discovery cache: when the dynamic command build
// panics (e.g. duplicate pflag registration, the pre-1.0.32 lock-out
// "flag redefined: params"), newLegacyPublicCommands must NOT propagate
// the panic. With no on-disk cache to quarantine there is nothing to
// self-heal from, so it degrades to the hardcoded helper commands and
// prints a stderr hint pointing at `dws cache refresh`.
func TestNewLegacyPublicCommandsPanicFallsBackToHelpers(t *testing.T) {
t.Setenv(cli.CacheDirEnv, t.TempDir())
calls := 0
orig := loadDynamicCommandsFn
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
calls++
panic("chat_permission_grant flag redefined: params")
}
t.Cleanup(func() { loadDynamicCommandsFn = orig })
var cmds []*cobra.Command
captured := captureStderr(t, func() {
cmds = newLegacyPublicCommands(context.Background(), nil)
})
if len(cmds) == 0 {
t.Fatalf("newLegacyPublicCommands() = 0 commands after build panic, want helper fallback set")
}
if !strings.Contains(captured, "dws cache refresh") {
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
}
if calls != 1 {
t.Errorf("dynamic build attempts = %d, want 1 (no cache on disk, nothing to quarantine and retry)", calls)
}
}
// TestNewLegacyPublicCommandsSelfHealsPoisonedCache verifies the self-heal
// path: when the build panics AND a discovery cache exists on disk, the
// partition is quarantined (moved aside, kept for inspection) and the build
// retried once. The retry succeeding means the user gets the full dynamic
// command tree with zero manual cache surgery.
func TestNewLegacyPublicCommandsSelfHealsPoisonedCache(t *testing.T) {
tmp := t.TempDir()
t.Setenv(cli.CacheDirEnv, tmp)
store := cache.NewStore(tmp)
partition := editionPartition()
if err := store.SaveTools(partition, "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
calls := 0
orig := loadDynamicCommandsFn
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
calls++
if calls == 1 {
panic("chat_permission_grant flag redefined: params")
}
return []*cobra.Command{{Use: "dynamic-probe"}}
}
t.Cleanup(func() { loadDynamicCommandsFn = orig })
var cmds []*cobra.Command
captured := captureStderr(t, func() {
cmds = newLegacyPublicCommands(context.Background(), nil)
})
if calls != 2 {
t.Fatalf("dynamic build attempts = %d, want 2 (initial + retry after quarantine)", calls)
}
found := false
for _, c := range cmds {
if c.Name() == "dynamic-probe" {
found = true
break
}
}
if !found {
t.Errorf("newLegacyPublicCommands() did not return the rebuilt dynamic command tree; got %d commands without 'dynamic-probe'", len(cmds))
}
quarantines, _ := filepath.Glob(filepath.Join(tmp, "*.quarantined"))
if len(quarantines) != 1 {
t.Fatalf("quarantine dirs = %v, want exactly 1", quarantines)
}
if _, err := os.Stat(filepath.Join(quarantines[0], "tools", "poisoned-server.json")); err != nil {
t.Errorf("poisoned snapshot not preserved in quarantine: %v", err)
}
if !strings.Contains(captured, "rebuilding from a fresh fetch") {
t.Errorf("stderr = %q, want a note about rebuilding from a fresh fetch", captured)
}
if strings.Contains(captured, "dws cache refresh") {
t.Errorf("stderr = %q, must not tell the user to run 'dws cache refresh' when the rebuild succeeded", captured)
}
}
// TestNewLegacyPublicCommandsSecondPanicDegradesToHelpers verifies the final
// safety net: if the rebuild after quarantine panics again (remote envelope
// still poisoned, or offline), the CLI degrades to helper commands and keeps
// the `dws cache refresh` hint.
func TestNewLegacyPublicCommandsSecondPanicDegradesToHelpers(t *testing.T) {
tmp := t.TempDir()
t.Setenv(cli.CacheDirEnv, tmp)
store := cache.NewStore(tmp)
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
calls := 0
orig := loadDynamicCommandsFn
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
calls++
panic("chat_permission_grant flag redefined: params")
}
t.Cleanup(func() { loadDynamicCommandsFn = orig })
var cmds []*cobra.Command
captured := captureStderr(t, func() {
cmds = newLegacyPublicCommands(context.Background(), nil)
})
if calls != 2 {
t.Fatalf("dynamic build attempts = %d, want 2 (initial + retry after quarantine)", calls)
}
if len(cmds) == 0 {
t.Fatalf("newLegacyPublicCommands() = 0 commands after repeated build panics, want helper fallback set")
}
if !strings.Contains(captured, "dws cache refresh") {
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
}
}
// TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath ensures the guard is
// transparent on the happy path: commands returned by the dynamic build
// still reach the caller unchanged.
func TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath(t *testing.T) {
orig := loadDynamicCommandsFn
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
return []*cobra.Command{{Use: "dynamic-probe"}}
}
t.Cleanup(func() { loadDynamicCommandsFn = orig })
cmds := newLegacyPublicCommands(context.Background(), nil)
found := false
for _, c := range cmds {
if c.Name() == "dynamic-probe" {
found = true
break
}
}
if !found {
t.Errorf("newLegacyPublicCommands() lost the dynamic command; got %d commands without 'dynamic-probe'", len(cmds))
}
}
+2 -2
View File
@@ -293,7 +293,7 @@ func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.
Status: "empty",
Request: &recovery.ToolCallRecord{
ServerID: "devdoc",
ToolName: "search_open_platform_docs",
ToolName: "search_open_platform_docs_rag",
Arguments: cloneRecoveryArgs(requestArgs),
},
},
@@ -302,7 +302,7 @@ func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.
retrieval.DocSearch.Status = "skipped"
return retrieval, nil
}
result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs", requestArgs)
result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs_rag", requestArgs)
if result != nil {
retrieval.DocSearch.Response = toRecoveryToolResponse(result)
}
+69 -1
View File
@@ -682,8 +682,76 @@ func newGenerateSkillsCommand() *cobra.Command {
return cmd
}
// buildMCPCommandFn is a test seam for newMCPCommand so a panic in the
// catalog-driven canonical build can be simulated without crafting a
// poisoned on-disk cache.
var buildMCPCommandFn = cli.NewMCPCommand
// newMCPCommand builds the canonical `dws mcp` tree, self-healing a poisoned
// cache when the build panics and degrading to an inert stub if that also
// fails.
//
// Why this guard exists: the canonical tree is assembled from cached catalog
// data BEFORE the legacy command build and before Cobra dispatches anything,
// so a panic here (e.g. a tool schema property named after the reserved
// --params flag, as cached during the 1.0.32 incident) used to abort every
// invocation — including `dws cache refresh` and `dws upgrade` — and was NOT
// covered by the legacy-path guards (#447/#452). Same two-staged recovery as
// buildEnvelopeCommandsSafe: quarantine the partition, retry once against a
// fresh fetch, then degrade with a `dws cache refresh` hint.
func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
return cli.NewMCPCommand(ctx, loader, runner, engine)
cmd, panicked := tryBuildMCPCommand(ctx, loader, runner, engine)
if panicked == nil {
return cmd
}
slog.Error("newMCPCommand: canonical command build panicked", "panic", panicked)
quarantined, qErr := cacheStoreFromEnv().QuarantinePartition(editionPartition())
if qErr != nil {
slog.Error("newMCPCommand: failed to quarantine discovery cache", "error", qErr)
}
if quarantined != "" {
fmt.Fprintf(os.Stderr,
"Warning: building canonical commands from the local discovery cache failed: %v\n"+
"The cached discovery data was moved to %s; rebuilding from a fresh fetch...\n",
panicked, quarantined)
cmd, panicked = tryBuildMCPCommand(ctx, loader, runner, engine)
if panicked == nil {
fmt.Fprintln(os.Stderr, "Canonical commands rebuilt successfully.")
return cmd
}
slog.Error("newMCPCommand: rebuild after cache quarantine panicked again, degrading to a stub", "panic", panicked)
}
fmt.Fprintf(os.Stderr,
"Warning: building canonical commands from the local discovery cache failed: %v\n"+
"The 'dws mcp' surface is temporarily unavailable; other commands still work.\n"+
"Run 'dws cache refresh' to rebuild the cache.\n", panicked)
buildErr := apperrors.NewInternal(fmt.Sprintf("canonical command build failed: %v; run 'dws cache refresh'", panicked))
stub := &cobra.Command{
Use: "mcp",
Short: "Canonical MCP-derived CLI surface (unavailable)",
Hidden: true,
Args: cobra.ArbitraryArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return buildErr
},
}
return stub
}
// tryBuildMCPCommand runs one attempt of the canonical build, converting a
// panic into a return value so the caller can decide between self-heal and
// degradation.
func tryBuildMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) (cmd *cobra.Command, panicked any) {
defer func() {
if r := recover(); r != nil {
cmd = nil
panicked = r
}
}()
return buildMCPCommandFn(ctx, loader, runner, engine), nil
}
// hideNonDirectRuntimeCommands marks top-level product commands as hidden
+24 -2
View File
@@ -111,7 +111,7 @@ func logHostOwnedPATDecisionOnce() {
hostOwnedPATDecisionOnce.Do(func() {
slog.Debug("runtime.host_owned_pat",
"hostOwned", authpkg.HostOwnsPATFlow(),
"agentCodeEnvPresent", os.Getenv(authpkg.AgentCodeEnv) != "",
"agentCodeEnvPresent", authpkg.AgentCodeEnvPresent(),
)
})
}
@@ -687,9 +687,31 @@ func resolveIdentityHeaders() map[string]string {
if sessionID == "" {
sessionID = os.Getenv(envRewindSessionID)
}
// Resolve the agent_code (accuracy-first; unknown hosts -> custom) and the
// per-(machine × agent_code) instance id. This is what makes agent_code
// actually report a value: previously it was sent only when the host
// injected DINGTALK_DWS_AGENTCODE (empty ~99.98% of the time), so the
// gateway logged no agent_code at all. DetectAgentCode always yields a code.
//
// Backward-compat by design (additive, not breaking):
// - x-dws-agent-id keeps its v1 meaning = machine-level install UUID
// (set by id.Headers() above), so old/new clients stay comparable.
// - x-dws-agent-instance-id is NEW: the per-(machine × agent_code) id.
// Old clients don't send it, which is itself a clean old/new signal.
// Note: x-dws-channel (DWS_CHANNEL) is a separate axis, untouched.
agentCode, agentCodeSig := authpkg.DetectAgentCode()
headers["x-dws-agent-instance-id"] = id.ResolveAgentID(defaultConfigDir(), agentCode, agentCodeSig)
// Emit the CLI version on the wire so the gateway can segment old vs new
// clients (and scope agent_code coverage / adoption). The header constant
// existed but was never set; wire it here.
if version != "" {
headers[transport.HeaderVersion] = version
}
envHeaders := map[string]string{
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
"x-dingtalk-dws-agent-code": strings.TrimSpace(os.Getenv(authpkg.AgentCodeEnv)),
"x-dingtalk-dws-agent-code": agentCode,
"x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID),
"x-dingtalk-session-id": sessionID,
"x-dingtalk-message-id": os.Getenv(envDingtalkMessageID),
+59
View File
@@ -328,6 +328,65 @@ func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
}
}
func TestResolveIdentityHeadersAgentIdentityFields(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv(authpkg.AgentCodeEnv, "qoder")
headers := resolveIdentityHeaders()
// x-dws-agent-id stays machine-level (v1 install UUID): non-empty and NOT
// the dwsa_ instance form — this is the cross-version continuity anchor.
machineID := headers["x-dws-agent-id"]
if machineID == "" {
t.Fatal("x-dws-agent-id must stay populated (machine-level)")
}
if strings.HasPrefix(machineID, "dwsa_") {
t.Fatalf("x-dws-agent-id must remain machine-level, got instance form %q", machineID)
}
// x-dws-agent-instance-id is the NEW per-(machine × agent_code) id.
instID := headers["x-dws-agent-instance-id"]
if !strings.HasPrefix(instID, "dwsa_") {
t.Fatalf("x-dws-agent-instance-id must be a derived instance id, got %q", instID)
}
if instID == machineID {
t.Fatal("instance id must differ from machine id")
}
// CLI version must now be on the wire so the gateway can segment old/new.
if headers[transport.HeaderVersion] == "" {
t.Fatalf("%s must be emitted", transport.HeaderVersion)
}
}
func TestResolveIdentityHeadersIgnoresReversedAgentCodeEnv(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv(authpkg.AgentCodeEnv, "")
t.Setenv("DWS_DINGTALK_AGENTCODE", " compat ")
// Isolate from ambient agent-host detection signals so this test asserts
// only the reversed-env-name behavior (the suite itself may run under
// Claude Code / Qoder / VS Code, whose signals would otherwise be detected).
for _, k := range []string{
"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT",
"OPENCLAW_BUNDLE_ROOT", "OPENCLAW_RUNTIME_ROLE", "HERMES_HOME",
"CODEX_SANDBOX", "VSCODE_BRAND", "__CFBundleIdentifier",
} {
t.Setenv(k, "")
}
headers := resolveIdentityHeaders()
// The reversed env name must never be consumed. With no canonical
// declaration and no host signature, agent_code resolves to the honest
// "custom" fallback — and crucially is NOT the reversed value.
got := headers["x-dingtalk-dws-agent-code"]
if got == "compat" {
t.Fatalf("x-dingtalk-dws-agent-code = %q, reversed env must be ignored", got)
}
if got != authpkg.AgentCodeCustom {
t.Fatalf("x-dingtalk-dws-agent-code = %q, want %q (fallback)", got, authpkg.AgentCodeCustom)
}
}
func TestResolveIdentityHeadersSessionEnvPriority(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv(envDingtalkSessionID, "ding-session")
+52
View File
@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
@@ -56,6 +57,7 @@ func newUpgradeCommand() *cobra.Command {
dws upgrade --list --all # 列出所有版本
dws upgrade --version v1.0.5 # 升级到指定版本
dws upgrade --rollback # 回滚到上一版本
dws upgrade --dry-run # 仅预览升级步骤,不实际执行
dws upgrade -y # 跳过确认直接升级`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -68,6 +70,7 @@ func newUpgradeCommand() *cobra.Command {
}
yes, _ := cmd.Flags().GetBool("yes")
dryRun, _ := cmd.Flags().GetBool("dry-run")
format := resolveUpgradeFormat(cmd)
if flagList {
@@ -88,6 +91,7 @@ func newUpgradeCommand() *cobra.Command {
force: flagForce,
skipSkills: flagSkipSkills,
yes: yes,
dryRun: dryRun,
})
},
}
@@ -108,6 +112,7 @@ type upgradeOptions struct {
force bool
skipSkills bool
yes bool
dryRun bool
}
// --- dws upgrade --check ---
@@ -298,6 +303,29 @@ func runUpgradeRollback(yes bool) error {
// Phase 2 (Apply): replace binary + install skills — only runs if Phase 1 fully succeeds.
// If anything fails in Phase 1, no files on disk are modified.
// writeDryRunPlan renders the steps that `dws upgrade` would perform, without
// touching the filesystem. Kept side-effect-free and writer-injectable so the
// --dry-run contract can be asserted in tests.
func writeDryRunPlan(w io.Writer, currentVer, binaryAssetName string, hasSkills bool) {
fmt.Fprintln(w)
fmt.Fprintf(w, " %s 预览模式,不会下载或修改任何文件\n", ugBold("[dry-run]"))
fmt.Fprintf(w, " 将执行以下操作:\n")
fmt.Fprintf(w, " [1/5] 备份当前版本 %s\n", ugDim(ensureV(currentVer)))
fmt.Fprintf(w, " [2/5] 下载 %s\n", ugCyan(binaryAssetName))
if hasSkills {
fmt.Fprintf(w, " 下载 %s\n", ugCyan("dws-skills.zip"))
}
fmt.Fprintf(w, " [3/5] 校验 SHA256\n")
fmt.Fprintf(w, " [4/5] 解压并验证\n")
replaceStep := "替换二进制"
if hasSkills {
replaceStep += " 并安装技能包"
}
fmt.Fprintf(w, " [5/5] %s\n", replaceStep)
fmt.Fprintln(w)
fmt.Fprintf(w, " %s\n", ugDim("移除 --dry-run 以实际执行升级"))
}
func runUpgrade(ctx context.Context, opts upgradeOptions) error {
fmt.Printf(" %s\n", ugDim("检查更新..."))
@@ -339,6 +367,20 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
fmt.Printf(" %s %s\n", ugBold("通道: "), ugYellow("pre-release"))
}
// --dry-run: preview only. Resolve the platform asset so a missing build is
// still reported, then describe the steps that *would* run and return before
// any side effect (no backup, no download, no replace). Matches the global
// flag's contract: "预览操作内容,不实际执行".
if opts.dryRun {
binaryAsset, err := upgrade.FindBinaryAsset(release.Assets)
if err != nil {
return err
}
hasSkills := upgrade.FindSkillsAsset(release.Assets) != nil && !opts.skipSkills
writeDryRunPlan(os.Stdout, currentVer, binaryAsset.Name, hasSkills)
return nil
}
if !opts.yes {
fmt.Println()
fmt.Printf("是否升级? [y/N] ")
@@ -518,6 +560,16 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
fmt.Printf(" %s\n", ugGreen("✓"))
}
// Clear discovery-derived caches so the upgraded binary rebuilds its
// command tree from a fresh fetch instead of inheriting snapshots written
// by the old version — a poisoned snapshot used to lock out every
// invocation before the build guards landed (#447 / #449).
if purged, purgeErr := cacheStoreFromEnv().PurgeDiscoveryData(); purgeErr != nil {
fmt.Printf(" %s %s\n", ugYellow("⚠"), ugDim(fmt.Sprintf("清理发现缓存失败 (可手动运行 dws cache refresh): %v", purgeErr)))
} else if len(purged) > 0 {
fmt.Printf(" %s %s\n", ugGreen("✓"), ugDim("发现缓存已清空, 新版本首次运行时自动重建"))
}
// Cleanup old backups
rm.Cleanup(5)
+54
View File
@@ -430,6 +430,60 @@ func TestNewUpgradeCommand_Help(t *testing.T) {
if !strings.Contains(help, "--rollback") {
t.Error("help should contain --rollback")
}
// Regression for #364: --dry-run must be discoverable from upgrade help so
// users know it is supported (and is now actually honored).
if !strings.Contains(help, "--dry-run") {
t.Error("help should advertise --dry-run for upgrade")
}
}
// --- writeDryRunPlan (#364) ---
//
// Regression for #364: `dws upgrade --dry-run` previously performed a real
// upgrade because the flag was silently ignored. The dry-run path must now be
// preview-only — it describes the steps without downloading or replacing
// anything. writeDryRunPlan is the side-effect-free renderer for that preview.
func TestWriteDryRunPlan_PreviewOnly(t *testing.T) {
var buf bytes.Buffer
writeDryRunPlan(&buf, "v1.0.30", "dws-darwin-arm64.tar.gz", false)
out := buf.String()
if !strings.Contains(out, "dry-run") {
t.Errorf("output should be marked as dry-run, got:\n%s", out)
}
if !strings.Contains(out, "不会下载或修改任何文件") {
t.Errorf("output should state nothing is downloaded or modified, got:\n%s", out)
}
if !strings.Contains(out, "dws-darwin-arm64.tar.gz") {
t.Errorf("output should name the resolved platform asset, got:\n%s", out)
}
// All five steps should be previewed, including the (skipped) replace step.
for _, step := range []string{"[1/5]", "[2/5]", "[3/5]", "[4/5]", "[5/5]"} {
if !strings.Contains(out, step) {
t.Errorf("output missing step %s, got:\n%s", step, out)
}
}
}
func TestWriteDryRunPlan_WithSkills(t *testing.T) {
var buf bytes.Buffer
writeDryRunPlan(&buf, "v1.0.30", "dws-linux-amd64.tar.gz", true)
out := buf.String()
if !strings.Contains(out, "dws-skills.zip") {
t.Errorf("with skills, output should mention dws-skills.zip, got:\n%s", out)
}
if !strings.Contains(out, "安装技能包") {
t.Errorf("with skills, replace step should mention installing skills, got:\n%s", out)
}
// Without skills, neither should appear.
var buf2 bytes.Buffer
writeDryRunPlan(&buf2, "v1.0.30", "dws-linux-amd64.tar.gz", false)
if strings.Contains(buf2.String(), "dws-skills.zip") {
t.Errorf("without skills, output should not mention dws-skills.zip, got:\n%s", buf2.String())
}
}
// --- isLikelyAMFIKill ---
+161
View File
@@ -0,0 +1,161 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// agent_code_detect.go resolves the agent_code — which agent HOST is driving
// dws (claudecode / qoder / cursor / vscode / openclaw / hermes / ...). It
// fills the x-dingtalk-dws-agent-code header for per-channel statistics.
//
// SEPARATE axis from DWS_CHANNEL / x-dws-channel (a distribution channel code);
// the two are never conflated here.
//
// Design contract — ACCURACY OVER COVERAGE, but maximize accurate coverage:
// - Prefer generalizable, host-declared signals so one rule covers a whole
// family (VSCODE_BRAND covers every VS Code fork, present and future).
// - Every per-host signature below is OBSERVED on a real host (live process
// env via `ps eww`, or the app bundle Info.plist), not guessed.
// - Anything unidentified falls back to AgentCodeCustom — never guess.
// - Deliberately NOT used: TERM_PROGRAM (reports the terminal, e.g. iTerm,
// not the agent host) and fuzzy parent-process name matching.
package auth
import (
"os"
"strings"
)
// AgentCodeCustom is the honest fallback for any host we cannot identify.
const AgentCodeCustom = "custom"
// hostSignature is a verified env fingerprint for a known agent host. EnvKeys
// match when any listed key is present and non-empty.
type hostSignature struct {
Code string
EnvKeys []string
}
// knownSignatures: CLI / daemon agents that inject a distinctive env var, which
// the dws subprocess they spawn inherits. All verified on a real machine
// (2026-06-16) via live process env / launch env — not guessed.
var knownSignatures = []hostSignature{
// Claude Code — verified: CLAUDECODE=1, CLAUDE_CODE_ENTRYPOINT=cli.
{Code: "claudecode", EnvKeys: []string{"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"}},
// OpenClaw — verified on the running daemon: OPENCLAW_BUNDLE_ROOT.
{Code: "openclaw", EnvKeys: []string{"OPENCLAW_BUNDLE_ROOT", "OPENCLAW_RUNTIME_ROLE"}},
// Hermes — verified on the running gateway: HERMES_HOME.
{Code: "hermes", EnvKeys: []string{"HERMES_HOME"}},
// OpenAI Codex — CODEX_SANDBOX is auto-set by Codex for the subprocesses it
// spawns (e.g. CODEX_SANDBOX=seatbelt on macOS), and Codex filters this
// CODEX_-prefixed name out of user .env to prevent spoofing — so its
// presence reliably means "running under Codex".
// Source: developers.openai.com/codex/concepts/sandboxing
{Code: "codex", EnvKeys: []string{"CODEX_SANDBOX"}},
}
// NOTE on coverage limits (honest, not a TODO to silently ignore):
// Most terminal agents (gemini-cli/antigravity, aider, opencode, qwen-code,
// crush, goose, kimi, amazon-q, continue, ...) expose NO reliable
// self-identifying env marker — only user-set API-key/config vars, which we
// must not key off (a user setting GEMINI_API_KEY is not "running under
// gemini"). They therefore resolve to custom unless they declare themselves.
//
// The authoritative, fully-general path to 100% coverage is the T0 declaration
// contract: a host sets DINGTALK_DWS_AGENTCODE=<code> when it launches dws.
// That is accurate for ANY agent (present or future) on ANY OS, and is what an
// integrating host should wire up. Auto-detection (signatures / VSCODE_BRAND /
// bundle id) is a best-effort supplement for hosts that have not declared.
// bundleIDToCode maps macOS app bundle identifiers to agent codes. The bundle
// id is exposed via __CFBundleIdentifier and inherited by child processes the
// IDE spawns (including dws), so it identifies the host even from an integrated
// terminal. Verified from each app's Info.plist (2026-06-16). Only known agent
// bundles map; everything else (iTerm, Terminal, ...) falls through to custom.
//
// macOS-only signal: __CFBundleIdentifier does not exist on Linux/Windows, so
// this map is simply a no-op there (os.Getenv returns "").
var bundleIDToCode = map[string]string{
"com.qoder.ide": "qoder",
"com.todesktop.230313mzl4w4u92": "cursor", // Cursor's ToDesktop bundle id
"com.microsoft.VSCode": "vscode",
"com.workbuddy.workbuddy": "workbuddy",
}
// DetectAgentCode resolves the agent_code via a confidence ladder and returns
// the normalized code plus the signal that decided it:
//
// T0 explicit host declaration (DINGTALK_DWS_AGENTCODE — dedicated field)
// T1 verified per-agent env signature (CLI/daemon agents)
// T2 VSCODE_BRAND value (every VS Code fork declares its brand)
// T3 macOS app bundle id (known agent bundles only)
// T4 fallback -> custom (never guess)
func DetectAgentCode() (code string, signal string) {
// T0: host explicitly declares its agent_code — highest confidence.
if v, name := AgentCodeFromEnv(); v != "" {
return normalizeAgentCode(v), "env:" + name
}
// T1: verified per-agent env signature (most specific — wins over the IDE
// it may be running inside).
for _, sig := range knownSignatures {
for _, k := range sig.EnvKeys {
if strings.TrimSpace(os.Getenv(k)) != "" {
return sig.Code, "sig:" + k
}
}
}
// T2: VS Code fork family. The brand value IS the host's self-declaration,
// so this single rule covers Qoder/Cursor/VS Code/Windsurf/Trae/Kiro/... —
// including forks that don't exist yet.
if b := strings.TrimSpace(os.Getenv("VSCODE_BRAND")); b != "" {
return normalizeAgentCode(b), "env:VSCODE_BRAND"
}
// T3: macOS app bundle id (known agent bundles only).
if id := strings.TrimSpace(os.Getenv("__CFBundleIdentifier")); id != "" {
if c, ok := bundleIDToCode[id]; ok {
return c, "bundle:" + id
}
}
// T4: unknown host — honest fallback, no guessing.
return AgentCodeCustom, "fallback"
}
// normalizeAgentCode maps host-declared names/brands to canonical agent_code
// values. Unrecognized but non-empty input is lowercased, space-stripped and
// kept as-is — still a host declaration, so still accurate (this is what gives
// automatic coverage of new VS Code forks via VSCODE_BRAND).
func normalizeAgentCode(raw string) string {
s := strings.ToLower(strings.TrimSpace(raw))
s = strings.ReplaceAll(s, " ", "")
switch s {
case "":
return AgentCodeCustom
case "claude", "claude-code", "claude_code", "claudecode":
return "claudecode"
case "qoder", "qoderwork":
return "qoder"
case "workbuddy", "work-buddy":
return "workbuddy"
case "visualstudiocode", "code", "code-oss", "vscode":
return "vscode"
case "cursor":
return "cursor"
case "windsurf":
return "windsurf"
case "trae", "traecn":
return "trae"
default:
return s
}
}
+187
View File
@@ -0,0 +1,187 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"strings"
"testing"
)
// agentCodeSignalEnvs is every env DetectAgentCode consults. Tests clear them
// all so each case starts clean (the suite itself runs under a real host).
var agentCodeSignalEnvs = []string{
AgentCodeEnv,
"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT",
"OPENCLAW_BUNDLE_ROOT", "OPENCLAW_RUNTIME_ROLE",
"HERMES_HOME", "CODEX_SANDBOX",
"VSCODE_BRAND", "__CFBundleIdentifier",
"TERM_PROGRAM", "DWS_CHANNEL",
}
func clearAgentCodeEnv(t *testing.T) {
t.Helper()
for _, k := range agentCodeSignalEnvs {
t.Setenv(k, "")
}
}
func TestDetectAgentCode_HostDeclaration_T0(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv(AgentCodeEnv, "Qoder")
code, sig := DetectAgentCode()
if code != "qoder" {
t.Fatalf("want qoder, got %q", code)
}
if !strings.HasPrefix(sig, "env:"+AgentCodeEnv) {
t.Fatalf("want env signal, got %q", sig)
}
}
func TestDetectAgentCode_VerifiedSignatures_T1(t *testing.T) {
cases := []struct {
env, val, want string
}{
{"CLAUDECODE", "1", "claudecode"},
{"CLAUDE_CODE_ENTRYPOINT", "cli", "claudecode"},
{"OPENCLAW_BUNDLE_ROOT", "/Users/x/.openclaw-bundle", "openclaw"},
{"HERMES_HOME", "/Users/x/.hermes", "hermes"},
{"CODEX_SANDBOX", "seatbelt", "codex"},
}
for _, c := range cases {
t.Run(c.env, func(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv(c.env, c.val)
code, sig := DetectAgentCode()
if code != c.want {
t.Fatalf("%s=%s: want %q, got %q", c.env, c.val, c.want, code)
}
if !strings.HasPrefix(sig, "sig:") {
t.Fatalf("want sig:* signal, got %q", sig)
}
})
}
}
func TestDetectAgentCode_VSCodeBrand_T2(t *testing.T) {
cases := map[string]string{
"Qoder": "qoder",
"Cursor": "cursor",
"Visual Studio Code": "vscode",
"Windsurf": "windsurf",
"Trae": "trae",
"SomeNewFork": "somenewfork", // generic coverage of future forks
}
for brand, want := range cases {
t.Run(brand, func(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv("VSCODE_BRAND", brand)
code, sig := DetectAgentCode()
if code != want {
t.Fatalf("VSCODE_BRAND=%q: want %q, got %q", brand, want, code)
}
if sig != "env:VSCODE_BRAND" {
t.Fatalf("want env:VSCODE_BRAND signal, got %q", sig)
}
})
}
}
func TestDetectAgentCode_BundleID_T3(t *testing.T) {
cases := map[string]string{
"com.qoder.ide": "qoder",
"com.todesktop.230313mzl4w4u92": "cursor",
"com.microsoft.VSCode": "vscode",
"com.workbuddy.workbuddy": "workbuddy",
}
for id, want := range cases {
t.Run(id, func(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv("__CFBundleIdentifier", id)
code, sig := DetectAgentCode()
if code != want {
t.Fatalf("bundle %q: want %q, got %q", id, want, code)
}
if !strings.HasPrefix(sig, "bundle:") {
t.Fatalf("want bundle:* signal, got %q", sig)
}
})
}
}
// An unknown bundle id (e.g. a plain terminal) must NOT be labeled — falls to
// custom.
func TestDetectAgentCode_UnknownBundleIsCustom(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv("__CFBundleIdentifier", "com.googlecode.iterm2")
code, _ := DetectAgentCode()
if code != AgentCodeCustom {
t.Fatalf("unknown bundle must be custom, got %q", code)
}
}
func TestDetectAgentCode_Fallback_Custom(t *testing.T) {
clearAgentCodeEnv(t)
code, sig := DetectAgentCode()
if code != AgentCodeCustom {
t.Fatalf("want custom, got %q", code)
}
if sig != "fallback" {
t.Fatalf("want fallback, got %q", sig)
}
}
// TERM_PROGRAM and DWS_CHANNEL must never decide agent_code.
func TestDetectAgentCode_IgnoresNoise(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv("TERM_PROGRAM", "iTerm.app")
t.Setenv("DWS_CHANNEL", "Qoderwork")
code, _ := DetectAgentCode()
if code != AgentCodeCustom {
t.Fatalf("noise must not decide agent_code; want custom, got %q", code)
}
}
// Precedence: explicit declaration (T0) > env signature (T1) > VSCODE_BRAND
// (T2). A CLI agent running inside an IDE reports the CLI agent.
func TestDetectAgentCode_Precedence(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv("CLAUDECODE", "1") // T1
t.Setenv("VSCODE_BRAND", "Qoder") // T2
if code, _ := DetectAgentCode(); code != "claudecode" {
t.Fatalf("T1 must beat T2, got %q", code)
}
t.Setenv(AgentCodeEnv, "workbuddy") // T0
if code, _ := DetectAgentCode(); code != "workbuddy" {
t.Fatalf("T0 must beat all, got %q", code)
}
}
func TestNormalizeAgentCode(t *testing.T) {
cases := map[string]string{
"claude": "claudecode",
"Claude-Code": "claudecode",
"CLAUDECODE": "claudecode",
"Qoderwork": "qoder",
"WorkBuddy": "workbuddy",
"Visual Studio Code": "vscode",
"Cursor": "cursor",
"": AgentCodeCustom,
"some-new-ide": "some-new-ide",
}
for in, want := range cases {
if got := normalizeAgentCode(in); got != want {
t.Errorf("normalizeAgentCode(%q) = %q, want %q", in, got, want)
}
}
}
+28 -9
View File
@@ -19,19 +19,38 @@ import (
)
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 is the primary per-spawn environment variable the host
// injects to declare "this process is driven by a third-party Agent host,
// render authorization UI yourselves".
AgentCodeEnv = "DINGTALK_DWS_AGENTCODE"
)
// AgentCodeFromEnv returns the effective host agent code and the env name that
// supplied it.
//
// Keep the public env surface intentionally single-spelled. The reversed
// DWS_DINGTALK_AGENTCODE draft name is not consumed, so host-owned PAT mode,
// gateway identity headers, and `pat chmod --agentCode` fallback all agree on
// the same stable signal: DINGTALK_DWS_AGENTCODE.
func AgentCodeFromEnv() (string, string) {
if value := strings.TrimSpace(os.Getenv(AgentCodeEnv)); value != "" {
return value, AgentCodeEnv
}
return "", ""
}
func AgentCodeEnvPresent() bool {
value, _ := AgentCodeFromEnv()
return value != ""
}
// HostOwnsPATFlow reports whether the current process is running under a
// third-party Agent host that will render the PAT authorization card
// itself. The 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.
// itself. The trigger is DINGTALK_DWS_AGENTCODE being non-empty. The CLI
// deliberately does not consult any other signal (DINGTALK_AGENT /
// DWS_CHANNEL / the wire claw-type header) for this decision so that
// server-side routing tags and the host-owned UI contract remain independent
// concerns.
func HostOwnsPATFlow() bool {
return strings.TrimSpace(os.Getenv(AgentCodeEnv)) != ""
return AgentCodeEnvPresent()
}
+2 -1
View File
@@ -52,6 +52,7 @@ type DeviceFlowProvider struct {
logger *slog.Logger
Output io.Writer
httpClient *http.Client
NoBrowser bool
}
func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider {
@@ -205,7 +206,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
}
dfPrintDeviceCodeBox(p.output(), authResp)
if authResp.VerificationURIComplete != "" {
if authResp.VerificationURIComplete != "" && !p.NoBrowser {
if bErr := openBrowser(authResp.VerificationURIComplete); bErr != nil && p.logger != nil {
p.logger.Debug("could not open browser", "error", bErr)
}
+151 -14
View File
@@ -13,17 +13,27 @@
// identity.go manages agent instance identification for tracking.
//
// Each agent installation gets a unique agentId (UUID v4) that persists across
// version upgrades but regenerates on reinstall. This identity is transparently
// injected into MCP HTTP headers for gateway-side data collection.
// Identity has two granularities, both injected into MCP HTTP headers for
// gateway-side statistics:
//
// - machineId: a stable per-install UUID v4 (persists across upgrades,
// regenerates on reinstall). Non-PII.
// - agentId: a per-(machine × agentCode) id derived deterministically from
// machineId + agent_code, so one machine running multiple agent hosts
// (e.g. claudecode + cursor) yields a distinct, idempotent agentId per
// agent_code. Computed client-side — no gateway round-trip required.
//
// The agent_code itself is resolved by DetectAgentCode (agent_code_detect.go).
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/json"
"fmt"
"math/big"
"os"
"path/filepath"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -31,14 +41,34 @@ import (
const identityFile = "identity.json"
// identityVersion is the current on-disk schema version. v1 files (no
// machineId/agents) are migrated transparently on load.
const identityVersion = 2
// AgentEntry records the derived agentId for a single agent_code on this
// machine.
type AgentEntry struct {
AgentID string `json:"agentId"`
FirstSeen string `json:"firstSeen,omitempty"`
Detect string `json:"detect,omitempty"` // signal that decided the agent_code
}
// Identity holds the agent instance identification fields.
//
// AgentID is retained for backward compatibility with v1 readers: on a fresh
// install it is written equal to MachineID, and a v1 file's agentId is migrated
// into MachineID on load.
type Identity struct {
AgentID string `json:"agentId"` // UUID v4, generated at install time
Source string `json:"source"` // data source, default "dws"
Version int `json:"version,omitempty"`
AgentID string `json:"agentId"` // v1 install UUID; == MachineID on v2 installs
MachineID string `json:"machineId,omitempty"` // stable per-install machine seed
Source string `json:"source"` // data source, default "dws"
Agents map[string]*AgentEntry `json:"agents,omitempty"` // agent_code -> derived agentId
}
// Load reads the identity from <configDir>/identity.json.
// Returns nil if the file does not exist or cannot be parsed.
// v1 files are migrated in-memory (machineId backfilled from agentId).
func Load(configDir string) *Identity {
path := filepath.Join(configDir, identityFile)
data, err := os.ReadFile(path)
@@ -49,21 +79,43 @@ func Load(configDir string) *Identity {
if err := json.Unmarshal(data, &id); err != nil {
return nil
}
if id.AgentID == "" {
if id.AgentID == "" && id.MachineID == "" {
return nil
}
id.migrate()
return &id
}
// migrate backfills v2 fields from a v1 file in-memory (does not persist).
func (id *Identity) migrate() {
if id.MachineID == "" {
id.MachineID = id.AgentID // v1 install UUID becomes the machine seed
}
if id.AgentID == "" {
id.AgentID = id.MachineID
}
if id.Source == "" {
id.Source = "dws"
}
if id.Agents == nil {
id.Agents = make(map[string]*AgentEntry)
}
id.Version = identityVersion
}
// EnsureExists loads existing identity or creates a new one if not present.
func EnsureExists(configDir string) *Identity {
if id := Load(configDir); id != nil {
return id
}
u := generateUUID()
id := &Identity{
AgentID: generateUUID(),
Source: "dws",
Version: identityVersion,
AgentID: u, // kept == MachineID for backward-compat
MachineID: u,
Source: "dws",
Agents: make(map[string]*AgentEntry),
}
// Best-effort persist — don't fail the CLI if write fails.
@@ -71,14 +123,51 @@ func EnsureExists(configDir string) *Identity {
return id
}
// Headers returns the identity as HTTP header key-value pairs.
// machineSeed returns the stable seed used to derive per-channel agentIds.
func (id *Identity) machineSeed() string {
if id.MachineID != "" {
return id.MachineID
}
return id.AgentID
}
// ResolveAgentID returns the per-(machine × agentCode) agentId, deriving and
// persisting it on first sight of an agentCode. Idempotent: the same machine
// and agentCode always yields the same id, which is what makes cumulative
// per-agent_code statistics possible. An empty agentCode is treated as the
// custom bucket.
func (id *Identity) ResolveAgentID(configDir, agentCode, signal string) string {
if agentCode == "" {
agentCode = AgentCodeCustom
}
if id.Agents == nil {
id.Agents = make(map[string]*AgentEntry)
}
if e, ok := id.Agents[agentCode]; ok && e.AgentID != "" {
return e.AgentID
}
aid := deriveAgentID(id.machineSeed(), agentCode)
id.Agents[agentCode] = &AgentEntry{
AgentID: aid,
FirstSeen: time.Now().UTC().Format(time.RFC3339),
Detect: signal,
}
_ = save(configDir, id) // best-effort cache; recomputable if it fails
return aid
}
// Headers returns the identity as static HTTP header key-value pairs.
// x-dws-agent-id carries the stable machine-level id (== v1 install UUID), kept
// continuous across versions. The per-(machine × agent_code) instance id is a
// SEPARATE header (x-dws-agent-instance-id) injected by the caller via
// ResolveAgentID — it does not override x-dws-agent-id.
func (id *Identity) Headers() map[string]string {
if id == nil {
return nil
}
h := make(map[string]string, 5)
if id.AgentID != "" {
h["x-dws-agent-id"] = id.AgentID
if seed := id.machineSeed(); seed != "" {
h["x-dws-agent-id"] = seed
}
if id.Source != "" {
h["x-dws-source"] = id.Source
@@ -104,6 +193,38 @@ func save(configDir string, id *Identity) error {
return os.WriteFile(filepath.Join(configDir, identityFile), data, config.FilePerm)
}
// deriveAgentID computes a stable, client-side agentId for a (machine,
// agentCode) pair: dwsa_<12 base62 chars of sha256(seed|agentCode)>.
// Deterministic and idempotent; no gateway allocation needed for statistics.
func deriveAgentID(seed, agentCode string) string {
sum := sha256.Sum256([]byte(seed + "|" + agentCode))
enc := base62Encode(sum[:])
for len(enc) < 12 {
enc = "0" + enc
}
return "dwsa_" + enc[:12]
}
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
func base62Encode(b []byte) string {
n := new(big.Int).SetBytes(b)
if n.Sign() == 0 {
return "0"
}
base := big.NewInt(62)
mod := new(big.Int)
var out []byte
for n.Sign() > 0 {
n.DivMod(n, base, mod)
out = append(out, base62Alphabet[mod.Int64()])
}
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return string(out)
}
// generateUUID produces a UUID v4 string.
func generateUUID() string {
var u [16]byte
@@ -113,6 +234,22 @@ func generateUUID() string {
}
u[6] = (u[6] & 0x0f) | 0x40 // version 4
u[8] = (u[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
u[0:4], u[4:6], u[6:8], u[8:10], u[10:16])
return fmtUUID(u)
}
func fmtUUID(u [16]byte) string {
const hexdig = "0123456789abcdef"
// 8-4-4-4-12 with dashes => 36 bytes
buf := make([]byte, 36)
pos := 0
for i := 0; i < 16; i++ {
if i == 4 || i == 6 || i == 8 || i == 10 {
buf[pos] = '-'
pos++
}
buf[pos] = hexdig[u[i]>>4]
buf[pos+1] = hexdig[u[i]&0x0f]
pos += 2
}
return string(buf)
}
+111
View File
@@ -0,0 +1,111 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDeriveAgentID_Format(t *testing.T) {
id := deriveAgentID("machine-abc", "claudecode")
if !strings.HasPrefix(id, "dwsa_") {
t.Fatalf("want dwsa_ prefix, got %q", id)
}
if len(id) != len("dwsa_")+12 {
t.Fatalf("want 12 base62 chars after prefix, got %q (len %d)", id, len(id))
}
}
func TestDeriveAgentID_Deterministic(t *testing.T) {
a := deriveAgentID("seed", "claudecode")
b := deriveAgentID("seed", "claudecode")
if a != b {
t.Fatalf("derivation must be deterministic: %q != %q", a, b)
}
}
func TestDeriveAgentID_DistinctByChannelAndMachine(t *testing.T) {
m1c1 := deriveAgentID("machine1", "claudecode")
m1c2 := deriveAgentID("machine1", "cursor")
m2c1 := deriveAgentID("machine2", "claudecode")
if m1c1 == m1c2 {
t.Errorf("same machine, different channel must differ: %q", m1c1)
}
if m1c1 == m2c1 {
t.Errorf("different machine, same channel must differ: %q", m1c1)
}
}
func TestResolveAgentID_IdempotentAndPersisted(t *testing.T) {
dir := t.TempDir()
id := EnsureExists(dir)
first := id.ResolveAgentID(dir, "claudecode", "sig:CLAUDECODE")
second := id.ResolveAgentID(dir, "claudecode", "sig:CLAUDECODE")
if first != second {
t.Fatalf("ResolveAgentID must be idempotent: %q != %q", first, second)
}
// Reload from disk — the channel entry must have persisted.
reloaded := Load(dir)
if reloaded == nil {
t.Fatal("expected identity to persist")
}
e, ok := reloaded.Agents["claudecode"]
if !ok || e.AgentID != first {
t.Fatalf("persisted agentId mismatch: %+v", reloaded.Agents)
}
if e.Detect != "sig:CLAUDECODE" {
t.Errorf("want detect signal recorded, got %q", e.Detect)
}
}
func TestResolveAgentID_EmptyAgentCodeGoesCustom(t *testing.T) {
dir := t.TempDir()
id := EnsureExists(dir)
got := id.ResolveAgentID(dir, "", "fallback")
want := id.ResolveAgentID(dir, AgentCodeCustom, "fallback")
if got != want {
t.Fatalf("empty agent_code must map to custom bucket: %q != %q", got, want)
}
}
// A v1 file ({agentId, source}) must migrate: machineId backfilled from the
// legacy agentId, and per-channel derivation keyed off that stable seed.
func TestLoad_MigratesV1(t *testing.T) {
dir := t.TempDir()
v1 := `{"agentId":"504ddd36-3acf-45f6-9c1f-82f99260a419","source":"dws"}`
if err := os.WriteFile(filepath.Join(dir, identityFile), []byte(v1), 0o600); err != nil {
t.Fatal(err)
}
id := Load(dir)
if id == nil {
t.Fatal("v1 file should load")
}
if id.MachineID != "504ddd36-3acf-45f6-9c1f-82f99260a419" {
t.Fatalf("machineId must backfill from legacy agentId, got %q", id.MachineID)
}
if id.machineSeed() != id.MachineID {
t.Fatalf("seed should be machineId, got %q", id.machineSeed())
}
// Derivation is stable against the migrated seed.
want := deriveAgentID(id.MachineID, "claudecode")
if got := id.ResolveAgentID(dir, "claudecode", "sig:CLAUDECODE"); got != want {
t.Fatalf("post-migration derivation mismatch: %q != %q", got, want)
}
}
+5 -2
View File
@@ -42,6 +42,7 @@ type OAuthProvider struct {
logger *slog.Logger
Output io.Writer
httpClient *http.Client
NoBrowser bool
}
// NewOAuthProvider creates a new OAuth provider.
@@ -393,8 +394,10 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
if p.logger != nil {
p.logger.Debug("authorization URL", "url", authURL)
}
if err := openBrowser(authURL); err != nil && p.logger != nil {
p.logger.Warn(i18n.T("无法自动打开浏览器"), "error", err)
if !p.NoBrowser {
if err := openBrowser(authURL); err != nil && p.logger != nil {
p.logger.Warn(i18n.T("无法自动打开浏览器"), "error", err)
}
}
_, _ = fmt.Fprintln(p.output(), "")
+68
View File
@@ -238,6 +238,74 @@ func (s *Store) DeleteDetail(partition, serverKey string) error {
return nil
}
// QuarantinePartition moves the entire on-disk cache for a partition aside,
// renaming it to "<partition>.quarantined", so the next load starts from an
// empty cache while the poisoned snapshot stays on disk for inspection.
// Returns the quarantine path, or "" when the partition has no cache on disk.
// A previous quarantine for the same partition is replaced, so repeated
// quarantines never accumulate.
func (s *Store) QuarantinePartition(partition string) (string, error) {
dir := filepath.Join(s.Root, sanitize(partition))
if _, err := os.Stat(dir); err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
quarantine := dir + ".quarantined"
if err := os.RemoveAll(quarantine); err != nil {
return "", err
}
if err := os.Rename(dir, quarantine); err != nil {
return "", err
}
return quarantine, nil
}
// discoverySubdirs are the per-partition directories holding discovery-derived
// data: the market registry envelope plus tools / detail snapshots.
var discoverySubdirs = []string{"market", "tools", "detail"}
// PurgeDiscoveryData deletes the discovery-derived cache for every partition
// under the cache root, leaving unrelated data that shares the root (e.g. the
// upgrade download cache in "downloads/") untouched. Returns the names of the
// partition directories that had data removed. Removal errors are collected
// into the returned error but do not stop the sweep.
func (s *Store) PurgeDiscoveryData() ([]string, error) {
entries, err := os.ReadDir(s.Root)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var purged []string
var firstErr error
for _, entry := range entries {
if !entry.IsDir() {
continue
}
removedAny := false
for _, sub := range discoverySubdirs {
dir := filepath.Join(s.Root, entry.Name(), sub)
if _, statErr := os.Stat(dir); statErr != nil {
continue
}
if rmErr := os.RemoveAll(dir); rmErr != nil {
if firstErr == nil {
firstErr = rmErr
}
continue
}
removedAny = true
}
if removedAny {
purged = append(purged, entry.Name())
}
}
return purged, firstErr
}
func (s *Store) registryPath(partition string) string {
return filepath.Join(s.Root, sanitize(partition), "market", "servers.json")
}
+131
View File
@@ -0,0 +1,131 @@
// 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 cache
import (
"os"
"path/filepath"
"testing"
)
func TestQuarantinePartitionNoCacheIsNoop(t *testing.T) {
s := NewStore(t.TempDir())
path, err := s.QuarantinePartition("default_default")
if err != nil {
t.Fatalf("QuarantinePartition() error = %v", err)
}
if path != "" {
t.Errorf("QuarantinePartition() = %q, want empty path when nothing is cached", path)
}
}
func TestQuarantinePartitionMovesCacheAside(t *testing.T) {
tmp := t.TempDir()
s := NewStore(tmp)
if err := s.SaveTools("default_default", "srv", ToolsSnapshot{ServerKey: "srv"}); err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
path, err := s.QuarantinePartition("default_default")
if err != nil {
t.Fatalf("QuarantinePartition() error = %v", err)
}
want := filepath.Join(tmp, "default_default.quarantined")
if path != want {
t.Errorf("QuarantinePartition() = %q, want %q", path, want)
}
if _, statErr := os.Stat(filepath.Join(tmp, "default_default")); !os.IsNotExist(statErr) {
t.Errorf("original partition dir still present after quarantine (stat err = %v)", statErr)
}
if _, statErr := os.Stat(filepath.Join(path, "tools", "srv.json")); statErr != nil {
t.Errorf("quarantined snapshot missing: %v", statErr)
}
}
func TestQuarantinePartitionReplacesPreviousQuarantine(t *testing.T) {
tmp := t.TempDir()
s := NewStore(tmp)
if err := s.SaveTools("default_default", "first", ToolsSnapshot{ServerKey: "first"}); err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
if _, err := s.QuarantinePartition("default_default"); err != nil {
t.Fatalf("first QuarantinePartition() error = %v", err)
}
if err := s.SaveTools("default_default", "second", ToolsSnapshot{ServerKey: "second"}); err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
path, err := s.QuarantinePartition("default_default")
if err != nil {
t.Fatalf("second QuarantinePartition() error = %v", err)
}
if _, statErr := os.Stat(filepath.Join(path, "tools", "second.json")); statErr != nil {
t.Errorf("latest quarantine missing newest snapshot: %v", statErr)
}
if _, statErr := os.Stat(filepath.Join(path, "tools", "first.json")); !os.IsNotExist(statErr) {
t.Errorf("previous quarantine was not replaced (stat err = %v)", statErr)
}
}
func TestPurgeDiscoveryDataRemovesDiscoveryDirsOnly(t *testing.T) {
tmp := t.TempDir()
s := NewStore(tmp)
mustWrite := func(parts ...string) {
t.Helper()
path := filepath.Join(parts...)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%s) error = %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
t.Fatalf("WriteFile(%s) error = %v", path, err)
}
}
mustWrite(tmp, "default_default", "market", "servers.json")
mustWrite(tmp, "default_default", "tools", "srv.json")
mustWrite(tmp, "default_default", "detail", "srv.json")
mustWrite(tmp, "wukong_default", "tools", "srv.json")
// Unrelated data sharing the cache root must survive the purge.
mustWrite(tmp, "downloads", "dws-1.0.36.tar.gz")
purged, err := s.PurgeDiscoveryData()
if err != nil {
t.Fatalf("PurgeDiscoveryData() error = %v", err)
}
if len(purged) != 2 {
t.Fatalf("PurgeDiscoveryData() purged = %v, want 2 partitions", purged)
}
for _, sub := range []string{"market", "tools", "detail"} {
if _, statErr := os.Stat(filepath.Join(tmp, "default_default", sub)); !os.IsNotExist(statErr) {
t.Errorf("%s dir survived the purge (stat err = %v)", sub, statErr)
}
}
if _, statErr := os.Stat(filepath.Join(tmp, "wukong_default", "tools")); !os.IsNotExist(statErr) {
t.Errorf("second partition tools dir survived the purge (stat err = %v)", statErr)
}
if _, statErr := os.Stat(filepath.Join(tmp, "downloads", "dws-1.0.36.tar.gz")); statErr != nil {
t.Errorf("unrelated downloads data was removed: %v", statErr)
}
}
func TestPurgeDiscoveryDataMissingRootIsNoop(t *testing.T) {
s := NewStore(filepath.Join(t.TempDir(), "does-not-exist"))
purged, err := s.PurgeDiscoveryData()
if err != nil {
t.Fatalf("PurgeDiscoveryData() error = %v", err)
}
if len(purged) != 0 {
t.Errorf("PurgeDiscoveryData() purged = %v, want none", purged)
}
}
+36 -7
View File
@@ -534,6 +534,34 @@ func newToolCommand(product ir.CanonicalProduct, tool ir.ToolDescriptor, runner
return cmd
}
// canRegisterToolFlag reports whether a long flag named name can be
// registered on cmd without panicking pflag ("flag redefined"). The reserved
// payload names are excluded too: newToolCommand unconditionally registers
// --json/--params before the spec loop. Tool schemas are remote data — a
// property named after a reserved or already-registered flag must degrade to
// "flag unavailable" (the value stays reachable through --json/--params),
// never abort the process. Mirrors internal/compat's canRegisterFlag.
func canRegisterToolFlag(cmd *cobra.Command, name string) bool {
if name == "" || name == "json" || name == "params" {
return false
}
return cmd.Flags().Lookup(name) == nil
}
// safeToolShorthand returns short when it is a single-character shorthand not
// yet bound on cmd; otherwise "" (drop the shorthand, keep the long flag).
// pflag panics on both multi-character and duplicate shorthands.
func safeToolShorthand(cmd *cobra.Command, short string) string {
short = strings.TrimSpace(short)
if len(short) != 1 {
return ""
}
if cmd.Flags().ShorthandLookup(short) != nil {
return ""
}
return short
}
func applyFlagSpecs(cmd *cobra.Command, specs []FlagSpec) {
for _, spec := range specs {
usage := spec.Description
@@ -541,41 +569,42 @@ func applyFlagSpecs(cmd *cobra.Command, specs []FlagSpec) {
usage = fmt.Sprintf("Override %s", spec.PropertyName)
}
primary := strings.TrimSpace(spec.FlagName)
if primary == "" {
if !canRegisterToolFlag(cmd, primary) {
continue
}
shorthand := safeToolShorthand(cmd, spec.Shorthand)
alias := strings.TrimSpace(spec.Alias)
if alias == primary {
if alias == primary || !canRegisterToolFlag(cmd, alias) {
alias = ""
}
switch spec.Kind {
case flagString, flagJSON:
cmd.Flags().StringP(primary, spec.Shorthand, "", usage)
cmd.Flags().StringP(primary, shorthand, "", usage)
if alias != "" {
cmd.Flags().String(alias, "", usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
}
case flagInteger:
cmd.Flags().IntP(primary, spec.Shorthand, 0, usage)
cmd.Flags().IntP(primary, shorthand, 0, usage)
if alias != "" {
cmd.Flags().Int(alias, 0, usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
}
case flagNumber:
cmd.Flags().Float64P(primary, spec.Shorthand, 0, usage)
cmd.Flags().Float64P(primary, shorthand, 0, usage)
if alias != "" {
cmd.Flags().Float64(alias, 0, usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
}
case flagBoolean:
cmd.Flags().BoolP(primary, spec.Shorthand, false, usage)
cmd.Flags().BoolP(primary, shorthand, false, usage)
if alias != "" {
cmd.Flags().Bool(alias, false, usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
}
case flagStringArray, flagIntegerList, flagNumberList, flagBooleanList:
cmd.Flags().StringSliceP(primary, spec.Shorthand, nil, usage)
cmd.Flags().StringSliceP(primary, shorthand, nil, usage)
if alias != "" {
cmd.Flags().StringSlice(alias, nil, usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
+116
View File
@@ -0,0 +1,116 @@
// 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"
"github.com/spf13/cobra"
)
// newToolCommandFixture mirrors the flag environment of newToolCommand: the
// reserved payload flags are registered before the spec loop runs.
func newToolCommandFixture() *cobra.Command {
cmd := &cobra.Command{Use: "probe"}
cmd.Flags().String("json", "", "Base JSON object payload for this tool invocation")
cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
return cmd
}
// TestApplyFlagSpecsSkipsReservedNames locks in the fix for the 1.0.32-class
// lock-out: a tool schema property named after a reserved payload flag
// ("params", as cached during the chat_permission_grant incident, or "json")
// must be skipped instead of panicking pflag ("flag redefined") — that panic
// fires while the canonical tree is assembled, before Cobra dispatches
// anything, and used to kill every invocation including `dws cache refresh`
// and `dws upgrade`.
func TestApplyFlagSpecsSkipsReservedNames(t *testing.T) {
t.Parallel()
cmd := newToolCommandFixture()
applyFlagSpecs(cmd, []FlagSpec{
{PropertyName: "params", FlagName: "params", Kind: flagString, Description: "命令授权参数"},
{PropertyName: "json", FlagName: "json", Kind: flagString},
{PropertyName: "scope", FlagName: "scope", Kind: flagString},
})
if cmd.Flags().Lookup("scope") == nil {
t.Errorf("non-colliding flag --scope was not registered")
}
// The reserved flags must keep their payload usage strings, proving the
// schema-derived specs did not touch them.
if got := cmd.Flags().Lookup("params").Usage; got != "Additional JSON object payload merged after --json" {
t.Errorf("--params usage = %q, want the reserved payload usage", got)
}
}
// TestApplyFlagSpecsSkipsDuplicates covers duplicate property names within a
// single tool schema (or a spec colliding with an already-applied one).
func TestApplyFlagSpecsSkipsDuplicates(t *testing.T) {
t.Parallel()
cmd := newToolCommandFixture()
applyFlagSpecs(cmd, []FlagSpec{
{PropertyName: "scope", FlagName: "scope", Kind: flagString, Description: "first"},
{PropertyName: "scope", FlagName: "scope", Kind: flagBoolean, Description: "second"},
})
flag := cmd.Flags().Lookup("scope")
if flag == nil {
t.Fatalf("--scope was not registered at all")
}
if flag.Usage != "first" {
t.Errorf("--scope usage = %q, want the first spec to win", flag.Usage)
}
}
// TestApplyFlagSpecsSkipsCollidingAlias verifies an alias colliding with a
// reserved or existing flag is dropped while the primary still registers.
func TestApplyFlagSpecsSkipsCollidingAlias(t *testing.T) {
t.Parallel()
cmd := newToolCommandFixture()
applyFlagSpecs(cmd, []FlagSpec{
{PropertyName: "scope", FlagName: "scope", Alias: "params", Kind: flagString},
})
if cmd.Flags().Lookup("scope") == nil {
t.Errorf("primary flag --scope was not registered when its alias collided")
}
if got := cmd.Flags().Lookup("params").Usage; got != "Additional JSON object payload merged after --json" {
t.Errorf("--params usage = %q, alias overwrote the reserved payload flag", got)
}
}
// TestApplyFlagSpecsSanitizesShorthand verifies multi-character and duplicate
// shorthands (both pflag panics) degrade to long-flag-only registration.
func TestApplyFlagSpecsSanitizesShorthand(t *testing.T) {
t.Parallel()
cmd := newToolCommandFixture()
applyFlagSpecs(cmd, []FlagSpec{
{PropertyName: "alpha", FlagName: "alpha", Shorthand: "ab", Kind: flagString},
{PropertyName: "beta", FlagName: "beta", Shorthand: "s", Kind: flagString},
{PropertyName: "gamma", FlagName: "gamma", Shorthand: "s", Kind: flagString},
})
for _, name := range []string{"alpha", "beta", "gamma"} {
if cmd.Flags().Lookup(name) == nil {
t.Errorf("--%s was not registered", name)
}
}
if flag := cmd.Flags().ShorthandLookup("s"); flag == nil || flag.Name != "beta" {
t.Errorf("shorthand -s should stay bound to the first claimant --beta, got %v", flag)
}
}
+1 -1
View File
@@ -381,7 +381,7 @@ func TestBuildDynamicCommands_PositionalWithFlagAliases(t *testing.T) {
"article": {Description: "文档文章"},
},
ToolOverrides: map[string]market.CLIToolOverride{
"search_open_platform_docs": {
"search_open_platform_docs_rag": {
CLIName: "search",
Group: "article",
Flags: map[string]market.CLIFlagOverride{
+1 -1
View File
@@ -185,7 +185,7 @@ func executePipelineCall(
return nil, err
}
actual := getDotPath(resp, step.PollUntilField)
if actual != nil && fmt.Sprint(actual) == step.PollUntilValue {
if actual != nil && strings.EqualFold(fmt.Sprint(actual), step.PollUntilValue) {
return resp, nil
}
if time.Now().After(deadline) {
+50 -11
View File
@@ -415,6 +415,33 @@ func parseFlagDefault(kind ValueKind, raw string) (defStr string, defInt int, de
return
}
// canRegisterFlag reports whether a long flag named name can be registered
// on cmd without panicking pflag ("flag redefined"). The reserved payload
// names are excluded too: ApplyBindings unconditionally registers hidden
// --json/--params after the bindings loop. The envelope is remote data —
// a duplicate or reserved name there must degrade to "flag unavailable",
// never abort the process.
func canRegisterFlag(cmd *cobra.Command, name string) bool {
if name == "" || name == "json" || name == "params" {
return false
}
return cmd.Flags().Lookup(name) == nil
}
// safeShorthand returns short when it is a single-character shorthand not
// yet bound on cmd; otherwise "" (drop the shorthand, keep the long flag).
// pflag panics on both multi-character and duplicate shorthands.
func safeShorthand(cmd *cobra.Command, short string) string {
short = strings.TrimSpace(short)
if len(short) != 1 {
return ""
}
if cmd.Flags().ShorthandLookup(short) != nil {
return ""
}
return short
}
func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
for _, binding := range bindings {
// Positional bindings are collected from cobra args rather than flags.
@@ -464,7 +491,7 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default)
registerHidden := func(name string, suffix string) {
if name == "" {
if !canRegisterFlag(cmd, name) {
return
}
switch binding.Kind {
@@ -484,19 +511,27 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
_ = cmd.Flags().MarkHidden(name)
}
if !canRegisterFlag(cmd, primary) {
// Duplicate or reserved primary name in the envelope. Skip the
// whole binding: CollectBindings tolerates the missing flag
// (Lookup → nil → continue) and the value can still be supplied
// via the --params payload.
continue
}
short := safeShorthand(cmd, binding.Short)
switch binding.Kind {
case ValueString:
cmd.Flags().StringP(primary, binding.Short, defStr, binding.Usage)
cmd.Flags().StringP(primary, short, defStr, binding.Usage)
case ValueInt:
cmd.Flags().IntP(primary, binding.Short, defInt, binding.Usage)
cmd.Flags().IntP(primary, short, defInt, binding.Usage)
case ValueFloat:
cmd.Flags().Float64P(primary, binding.Short, defFloat, binding.Usage)
cmd.Flags().Float64P(primary, short, defFloat, binding.Usage)
case ValueBool:
cmd.Flags().BoolP(primary, binding.Short, defBool, binding.Usage)
cmd.Flags().BoolP(primary, short, defBool, binding.Usage)
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
cmd.Flags().StringSliceP(primary, binding.Short, defSlice, binding.Usage)
cmd.Flags().StringSliceP(primary, short, defSlice, binding.Usage)
case ValueJSON:
cmd.Flags().StringP(primary, binding.Short, defStr, binding.Usage+" (JSON)")
cmd.Flags().StringP(primary, short, defStr, binding.Usage+" (JSON)")
}
registerHidden(alias, " (alias)")
for _, extra := range extras {
@@ -513,8 +548,12 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
}
}
}
cmd.Flags().String("json", "", "Base JSON object payload for this command")
cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
if cmd.Flags().Lookup("json") == nil {
cmd.Flags().String("json", "", "Base JSON object payload for this command")
}
if cmd.Flags().Lookup("params") == nil {
cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
}
_ = cmd.Flags().MarkHidden("json")
_ = cmd.Flags().MarkHidden("params")
}
@@ -553,12 +592,12 @@ func registerPositionalAliasFlags(cmd *cobra.Command, binding FlagBinding) {
defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default)
register := func(name string, withShort bool, hidden bool, usageSuffix string) {
if name == "" {
if !canRegisterFlag(cmd, name) {
return
}
short := ""
if withShort {
short = binding.Short
short = safeShorthand(cmd, binding.Short)
}
usage := binding.Usage + usageSuffix
switch binding.Kind {
+135
View File
@@ -0,0 +1,135 @@
// 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 (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
)
// The envelope is remote data; none of these malformed shapes may panic the
// command build — pflag panics on duplicate long names, duplicate shorthands,
// and multi-character shorthands, and a poisoned discovery cache used to take
// down every CLI invocation this way (pre-1.0.32 lockout class).
func TestBuildDynamicCommandsSurvivesMalformedFlagEnvelope(t *testing.T) {
tests := []struct {
name string
flags map[string]market.CLIFlagOverride
}{
{
name: "duplicate shorthand across two flags",
flags: map[string]market.CLIFlagOverride{
"alpha": {Shorthand: "x"},
"beta": {Shorthand: "x"},
},
},
{
name: "multi-character shorthand",
flags: map[string]market.CLIFlagOverride{
"alpha": {Shorthand: "xy"},
},
},
{
name: "primary collides with reserved payload flag",
flags: map[string]market.CLIFlagOverride{
"params": {},
"json": {},
},
},
{
name: "cross-binding duplicate primary via alias",
flags: map[string]market.CLIFlagOverride{
"user_id": {Alias: "target"},
"member_id": {Alias: "target"},
},
},
{
name: "cross-binding alias collides with another primary",
flags: map[string]market.CLIFlagOverride{
"alpha": {},
"beta": {Aliases: []string{"alpha"}},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-guard",
CLI: market.CLIOverlay{
ID: "guard",
Command: "guard",
ToolOverrides: map[string]market.CLIToolOverride{
"guard_tool": {
CLIName: "boom",
Flags: tc.flags,
},
},
},
},
}
// Must not panic; the command must build and stay executable.
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
if len(cmds) != 1 {
t.Fatalf("BuildDynamicCommands() = %d commands, want 1", len(cmds))
}
cmds[0].SetArgs([]string{"boom", "--help"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
if err := cmds[0].Execute(); err != nil {
t.Fatalf("execute --help: %v", err)
}
})
}
}
// TestBuildDynamicCommandsKeepsFirstShorthand pins the winner: when two
// flags claim the same shorthand, the first (sorted param order) keeps it
// and the second still registers its long flag.
func TestBuildDynamicCommandsKeepsFirstShorthand(t *testing.T) {
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-guard",
CLI: market.CLIOverlay{
ID: "guard",
Command: "guard",
ToolOverrides: map[string]market.CLIToolOverride{
"guard_tool": {
CLIName: "boom",
Flags: map[string]market.CLIFlagOverride{
"alpha": {Shorthand: "x"},
"beta": {Shorthand: "x"},
},
},
},
},
},
}
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
boom, _, err := cmds[0].Find([]string{"boom"})
if err != nil {
t.Fatalf("find boom: %v", err)
}
short := boom.Flags().ShorthandLookup("x")
if short == nil || short.Name != "alpha" {
t.Fatalf("shorthand -x bound to %v, want alpha", short)
}
if boom.Flags().Lookup("beta") == nil {
t.Fatalf("long flag --beta missing; dropping the shorthand must not drop the flag")
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ func TestResourceName(t *testing.T) {
input string
wantErr bool
}{
{name: "valid", input: "search_open_platform_docs"},
{name: "valid", input: "search_open_platform_docs_rag"},
{name: "valid-cjk", input: "审批查询"},
{name: "leading-digit", input: "1tool", wantErr: true},
{name: "shell-char", input: "tool;rm", wantErr: true},
+19 -1
View File
@@ -23,6 +23,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/pkg/edition"
"github.com/spf13/cobra"
)
@@ -333,9 +334,19 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("file-type", "", "文件类型/扩展名 (msg-type=file)")
cmd.Flags().String("file-path", "", "文件展示路径 (msg-type=file)")
cmd.Flags().Int64("file-size", 0, "文件大小,单位字节 (msg-type=file)")
cmd.Flags().Bool("ai-tag", false, "标记为「通过AI发送」(默认不带;仅传 --ai-tag 时才在消息下方显示 AI 发送角标)")
return cmd
}
// attachAITag 仅在用户显式传入 --ai-tag 时,给发送参数加上 clawType,
// 由 IM 服务端据此渲染「通过AI发送」角标 (悟空版渲染「悟空AI发送」)。
// 默认不带:是否标记 AI 发送交由用户自行选择,不强加。
func attachAITag(cmd *cobra.Command, params map[string]any) {
if on, _ := cmd.Flags().GetBool("ai-tag"); on {
params["clawType"] = edition.ClawType()
}
}
// deriveTitleFromText 在未显式指定 --title 时,从正文截取一个标题
// (首行、最多 20 个字符),与 wukong 行为对齐 (send_personal_message 的
// content 内携带 title)。
@@ -442,6 +453,7 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
return nil, "", apperrors.NewValidation("unsupported --msg-type: " + msgType + " (supported: image, file)")
}
params := map[string]any{"msgType": msgType, "content": contentJSON}
attachAITag(cmd, params)
if strings.TrimSpace(uuid) != "" {
params["uuid"] = uuid
}
@@ -481,6 +493,7 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
"msgType": "markdown",
"content": marshalMessageContent(title, text),
}
attachAITag(cmd, params)
if atAll {
params["atAll"] = true
}
@@ -493,6 +506,7 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
return params, "send_personal_message", nil
case hasUser:
params := map[string]any{"title": title, "text": text, "receiverUserId": user}
attachAITag(cmd, params)
return params, "send_direct_message_as_user", nil
default:
params := map[string]any{
@@ -500,6 +514,7 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
"msgType": "markdown",
"content": marshalMessageContent(title, text),
}
attachAITag(cmd, params)
if strings.TrimSpace(uuid) != "" {
params["uuid"] = uuid
}
@@ -975,8 +990,10 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
"openConversationId": convID,
"msgType": "reply",
"content": contentJSON,
"clawType": "wukong",
}
// clawType 仅在 --ai-tag 时携带;默认不带,回复不强加 AI 角标。
// edition 决定取值 (开源 openClaw / 悟空 wukong)。
attachAITag(cmd, params)
if uuid, _ := cmd.Flags().GetString("uuid"); strings.TrimSpace(uuid) != "" {
params["uuid"] = uuid
}
@@ -1000,6 +1017,7 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("ref-sender", "", "被引用消息发送者 openDingTalkId (必填)")
cmd.Flags().String("text", "", "回复正文 (必填)")
cmd.Flags().String("uuid", "", "可选 uuid(幂等标识)")
cmd.Flags().Bool("ai-tag", false, "标记为「通过AI发送」(默认不带;仅传 --ai-tag 时才显示 AI 发送角标)")
return cmd
}
+100
View File
@@ -7,6 +7,8 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type captureRunner struct {
@@ -308,6 +310,104 @@ func TestChatMessageSendRejectsAtMentionsOutsideGroup(t *testing.T) {
}
}
// TestChatMessageAITagControlsClawType guards the opt-in "Send from AI" indicator:
// by default NO user-identity send carries the clawType tool argument (so the IM
// server renders no AI badge). Only when --ai-tag is passed does each path attach
// the edition claw identity (open-source build pins it to edition.DefaultOSSClawType,
// "openClaw"); the wukong overlay would attach its own value. The label is opt-in so
// dws does not surprise users by branding every message they send.
func TestChatMessageAITagControlsClawType(t *testing.T) {
cases := []struct {
name string
make func(runner executor.Runner) *cobra.Command
args []string
}{
{
name: "group-markdown",
make: newChatMessageSendCommand,
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "hello"},
},
{
name: "user-direct",
make: newChatMessageSendCommand,
args: []string{"--user", "034766", "--title", "t", "--text", "hi"},
},
{
name: "open-dingtalk-id-direct",
make: newChatMessageSendCommand,
args: []string{"--open-dingtalk-id", "OP123", "--title", "t", "--text", "hi"},
},
{
name: "group-rich-media-image",
make: newChatMessageSendCommand,
args: []string{"--group", "cid-xyz", "--msg-type", "image", "--media-id", "media-1"},
},
{
name: "reply",
make: newChatMessageReplyCommand,
args: []string{
"--conversation-id", "cid-xyz",
"--ref-msg-id", "msg-1",
"--ref-sender", "op-1",
"--text", "got it",
},
},
}
for _, tc := range cases {
// Default: no --ai-tag → must omit clawType entirely (no badge).
t.Run(tc.name+"/default-no-tag", func(t *testing.T) {
runner := &captureRunner{}
cmd := tc.make(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 v, ok := runner.last.Params["clawType"]; ok {
t.Fatalf("default send must omit clawType, got %#v", v)
}
})
// Opt-in: --ai-tag → attach the edition claw identity.
t.Run(tc.name+"/with-ai-tag", func(t *testing.T) {
runner := &captureRunner{}
cmd := tc.make(runner)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs(append(append([]string{}, tc.args...), "--ai-tag"))
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
got, ok := runner.last.Params["clawType"]
if !ok {
t.Fatalf("--ai-tag send missing clawType; got %#v", runner.last.Params)
}
if got != edition.DefaultOSSClawType {
t.Fatalf("clawType = %#v, want %q", got, edition.DefaultOSSClawType)
}
})
}
}
// Robot sends are rendered as bot messages already; they must NOT carry the
// user-identity clawType argument.
func TestChatMessageSendByBotOmitsClawType(t *testing.T) {
runner := &captureRunner{}
cmd := newChatMessageSendByBotCommand(runner)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--group", "cid-xyz", "--robot-code", "robot-001", "--title", "t", "--text", "x"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if _, ok := runner.last.Params["clawType"]; ok {
t.Fatalf("bot send must not carry clawType; got %#v", runner.last.Params)
}
}
func equalAny(a, b any) bool {
switch av := a.(type) {
case []any:
+78 -1
View File
@@ -56,7 +56,19 @@ func (devdocHandler) Command(runner executor.Runner) *cobra.Command {
},
}
article.AddCommand(newDevdocArticleSearchCommand(runner))
errorCmd := &cobra.Command{
Use: "error",
Short: "错误排查",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
errorCmd.AddCommand(newDevdocErrorDiagnoseCommand(runner))
root.AddCommand(article)
root.AddCommand(errorCmd)
return root
}
@@ -82,7 +94,7 @@ func newDevdocArticleSearchCommand(runner executor.Runner) *cobra.Command {
if size < 1 {
size = 10
}
return runDevdocTool(cmd, runner, "search_open_platform_docs", map[string]any{
return runDevdocTool(cmd, runner, "search_open_platform_docs_rag", map[string]any{
"keyword": keyword,
"page": page,
"size": size,
@@ -97,6 +109,55 @@ func newDevdocArticleSearchCommand(runner executor.Runner) *cobra.Command {
return cmd
}
func newDevdocErrorDiagnoseCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "diagnose",
Aliases: []string{"troubleshoot"},
Short: "排查开放平台调用错误",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
requestID := devdocFlagOrFallback(cmd, "request-id", "trace-id")
errorCode := devdocFlagOrFallback(cmd, "error-code")
errorMessage := devdocFlagOrFallback(cmd, "error-message")
contextValue := devdocFlagOrFallback(cmd, "context")
query := devdocFlagOrFallback(cmd, "query")
hasPrimaryInput := query != "" || requestID != "" || errorCode != "" || errorMessage != "" || contextValue != ""
if !hasPrimaryInput {
return apperrors.NewValidation("one of --query, --request-id, --error-code, --error-message, or --context is required")
}
combinedQuery := devdocJoinQueryParts(query, errorMessage, devdocFlagOrFallback(cmd, "api"), contextValue)
page, _ := cmd.Flags().GetInt("page")
if page < 1 {
page = 1
}
size, _ := cmd.Flags().GetInt("size")
if size < 1 {
size = 10
}
params := map[string]any{
"page": page,
"size": size,
}
devdocSetStringParam(params, "query", combinedQuery)
devdocSetStringParam(params, "requestId", requestID)
devdocSetStringParam(params, "errorCode", errorCode)
return runDevdocTool(cmd, runner, "search_open_error_code_rag", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("query", "", "原始排查问题")
cmd.Flags().String("request-id", "", "开放平台 requestId")
addDevdocHiddenStringFlag(cmd, "trace-id", "--request-id 的兼容别名")
cmd.Flags().String("error-code", "", "错误码")
cmd.Flags().String("error-message", "", "错误描述,会合并进原始问题")
cmd.Flags().String("api", "", "API 名称,会合并进原始问题作为补充检索词")
cmd.Flags().String("context", "", "额外排查上下文,会合并进原始问题")
cmd.Flags().Int("page", 1, "分页页码 (从 1 开始,默认 1)")
cmd.Flags().Int("size", 10, "分页大小 (默认 10)")
return cmd
}
func runDevdocTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error {
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
@@ -125,6 +186,22 @@ func devdocFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string)
return ""
}
func devdocSetStringParam(params map[string]any, key, value string) {
if strings.TrimSpace(value) != "" {
params[key] = strings.TrimSpace(value)
}
}
func devdocJoinQueryParts(parts ...string) string {
cleaned := make([]string, 0, len(parts))
for _, part := range parts {
if trimmed := strings.TrimSpace(part); trimmed != "" {
cleaned = append(cleaned, trimmed)
}
}
return strings.Join(cleaned, " ")
}
func addDevdocHiddenStringFlag(cmd *cobra.Command, name, usage string) {
cmd.Flags().String(name, "", usage)
_ = cmd.Flags().MarkHidden(name)
+143 -2
View File
@@ -16,6 +16,7 @@ package helpers
import (
"bytes"
"context"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
@@ -43,8 +44,8 @@ func TestDevdocArticleSearchAcceptsWukongKeywordAlias(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != "search_open_platform_docs" {
t.Fatalf("tool = %q, want search_open_platform_docs", runner.last.Tool)
if runner.last.Tool != "search_open_platform_docs_rag" {
t.Fatalf("tool = %q, want search_open_platform_docs_rag", runner.last.Tool)
}
if got := runner.last.Params["keyword"]; got != "openConversationId" {
t.Fatalf("keyword = %#v, want openConversationId", got)
@@ -74,3 +75,143 @@ func TestDevdocArticleSearchAcceptsPositionalKeyword(t *testing.T) {
t.Fatalf("keyword = %#v, want MCP", got)
}
}
func TestDevdocErrorDiagnosePassesRequestID(t *testing.T) {
t.Parallel()
runner := &devdocCommandRunner{}
cmd := devdocHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"error", "diagnose", "--request-id", "req-123", "--page", "2", "--size", "5"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != "search_open_error_code_rag" {
t.Fatalf("tool = %q, want search_open_error_code_rag", runner.last.Tool)
}
if got := runner.last.Params["requestId"]; got != "req-123" {
t.Fatalf("requestId = %#v, want req-123", got)
}
if got := runner.last.Params["page"]; got != 2 {
t.Fatalf("page = %#v, want 2", got)
}
if got := runner.last.Params["size"]; got != 5 {
t.Fatalf("size = %#v, want 5", got)
}
}
func TestDevdocErrorDiagnoseMapsTraceIDAlias(t *testing.T) {
t.Parallel()
runner := &devdocCommandRunner{}
cmd := devdocHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"error", "diagnose", "--trace-id", "trace-abc", "--api", "创建日程"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if got := runner.last.Params["requestId"]; got != "trace-abc" {
t.Fatalf("requestId = %#v, want trace-abc", got)
}
if _, ok := runner.last.Params["traceId"]; ok {
t.Fatalf("traceId should not be sent, params = %#v", runner.last.Params)
}
if _, ok := runner.last.Params["apiName"]; ok {
t.Fatalf("apiName should not be sent, params = %#v", runner.last.Params)
}
if got := runner.last.Params["query"]; got != "创建日程" {
t.Fatalf("query = %#v, want 创建日程", got)
}
}
func TestDevdocErrorDiagnosePassesErrorContext(t *testing.T) {
t.Parallel()
runner := &devdocCommandRunner{}
cmd := devdocHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"error", "troubleshoot",
"--error-code", "33012",
"--error-message", "missing scope",
"--context", "create calendar failed",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if got := runner.last.Params["errorCode"]; got != "33012" {
t.Fatalf("errorCode = %#v, want 33012", got)
}
if _, ok := runner.last.Params["errorMessage"]; ok {
t.Fatalf("errorMessage should not be sent, params = %#v", runner.last.Params)
}
if _, ok := runner.last.Params["context"]; ok {
t.Fatalf("context should not be sent, params = %#v", runner.last.Params)
}
if got := runner.last.Params["query"]; got != "missing scope create calendar failed" {
t.Fatalf("query = %#v, want merged error context", got)
}
}
func TestDevdocErrorDiagnoseMergesAllContextIntoQuery(t *testing.T) {
t.Parallel()
runner := &devdocCommandRunner{}
cmd := devdocHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"error", "diagnose",
"--query", "机器人回调失败",
"--error-message", "missing scope",
"--api", "创建日程",
"--context", "应用无权限",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if got := runner.last.Tool; got != "search_open_error_code_rag" {
t.Fatalf("tool = %q, want search_open_error_code_rag", got)
}
if got := runner.last.Params["query"]; got != "机器人回调失败 missing scope 创建日程 应用无权限" {
t.Fatalf("query = %#v, want merged context", got)
}
for _, key := range []string{"apiName", "errorMessage", "context"} {
if _, ok := runner.last.Params[key]; ok {
t.Fatalf("%s should not be sent, params = %#v", key, runner.last.Params)
}
}
}
func TestDevdocErrorDiagnoseRequiresTroubleshootInput(t *testing.T) {
t.Parallel()
runner := &devdocCommandRunner{}
cmd := devdocHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"error", "diagnose", "--api", "创建日程"})
err := cmd.Execute()
if err == nil {
t.Fatal("Execute() error = nil, want validation error")
}
if !strings.Contains(err.Error(), "one of --query") {
t.Fatalf("error = %q, want required input hint", err.Error())
}
if runner.last.Tool != "" {
t.Fatalf("tool = %q, want no call", runner.last.Tool)
}
}
+42 -2
View File
@@ -357,7 +357,13 @@ func newDocCreateCommand(runner executor.Runner) *cobra.Command {
if sniffJsonMLLike(content) {
fmt.Fprintln(cmd.ErrOrStderr(), `warning: 输入内容看起来是 JSONML 结构;若要按 JSONML 解析,请加 --content-format jsonml,否则将按 markdown 解析。`)
}
params["markdown"] = content
if stripped, ok := stripLeadingDuplicateTitleHeading(content, name); ok {
fmt.Fprintln(cmd.ErrOrStderr(), `note: 正文首行与 --name 相同的一级标题已自动移除(文档标题会单独渲染为页面标题,保留会出现两个标题)。`)
content = stripped
}
if content != "" {
params["markdown"] = stripDocInputUnsafe(content)
}
}
} else if format, err := docContentFormat(cmd, "markdown", "jsonml"); err != nil {
return err
@@ -385,6 +391,40 @@ func newDocCreateCommand(runner executor.Runner) *cobra.Command {
return cmd
}
// stripLeadingDuplicateTitleHeading removes a leading markdown ATX H1 whose
// text equals the document name. The platform already renders the document
// name as the page title, so a body that opens with the same H1 displays the
// title twice (the "two headings" effect). Only an exact match (trimmed,
// case-insensitive) is removed; any other leading H1 is kept as intentional
// content. Reports whether a heading was stripped.
func stripLeadingDuplicateTitleHeading(content, name string) (string, bool) {
name = strings.TrimSpace(name)
if name == "" {
return content, false
}
body := strings.TrimLeft(content, "\ufeff \t\r\n")
line := body
rest := ""
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
line = body[:idx]
rest = body[idx+1:]
}
line = strings.TrimRight(line, " \t\r")
if !strings.HasPrefix(line, "# ") {
return content, false
}
heading := strings.TrimSpace(line[2:])
// ATX closing sequence ("# Title #") — only trim trailing hashes when
// separated by a space, so names that legitimately end with '#' survive.
if t := strings.TrimRight(heading, "#"); t != heading && strings.HasSuffix(t, " ") {
heading = strings.TrimSpace(t)
}
if !strings.EqualFold(heading, name) {
return content, false
}
return strings.TrimLeft(rest, "\r\n"), true
}
func newDocUpdateCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "update",
@@ -433,7 +473,7 @@ func newDocUpdateCommand(runner executor.Runner) *cobra.Command {
if sniffJsonMLLike(content) {
fmt.Fprintln(cmd.ErrOrStderr(), `warning: 输入内容看起来是 JSONML 结构;若要按 JSONML 解析,请加 --content-format jsonml,否则将按 markdown 解析。`)
}
params["markdown"] = content
params["markdown"] = stripDocInputUnsafe(content)
addDocIntParam(cmd, params, "index", "index")
}
return runDocTool(cmd, runner, "doc", "update_document", params)
+18 -3
View File
@@ -26,6 +26,7 @@ import (
var docDangerousUnicode = [...]rune{
0x200B,
0x200C,
0x200D,
0x200E,
0x200F,
0x202A,
@@ -33,6 +34,8 @@ var docDangerousUnicode = [...]rune{
0x202C,
0x202D,
0x202E,
0x2028,
0x2029,
0x2066,
0x2067,
0x2068,
@@ -49,8 +52,20 @@ var docDangerousSet = func() map[rune]bool {
return m
}()
func stripDocDangerousUnicode(s string) string {
// stripDocInputUnsafe removes characters that the server-side RejectControlChars
// validator (mirrored by apiclient.rejectDangerousChars) would reject:
//
// 1. C0 control characters (except tab and newline) and DEL (0x7F)
// 2. Dangerous Unicode (zero-width, Bidi controls, line/paragraph separators, BOM)
//
// It is applied at the write boundary so document content passes server
// validation instead of being rejected. Tab and newline are preserved because
// they are legitimate in document text.
func stripDocInputUnsafe(s string) string {
return strings.Map(func(r rune) rune {
if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7F) {
return -1
}
if docDangerousSet[r] {
return -1
}
@@ -194,7 +209,7 @@ func prepareDocJSONMLBody(cmd *cobra.Command, raw string) (string, error) {
if err != nil {
return "", fmt.Errorf("marshal normalized jsonml: %w", err)
}
return stripDocDangerousUnicode(string(out)), nil
return stripDocInputUnsafe(string(out)), nil
}
func prepareDocJSONMLNode(cmd *cobra.Command, rawElement string) (string, error) {
@@ -247,7 +262,7 @@ func prepareDocJSONMLNode(cmd *cobra.Command, rawElement string) (string, error)
if err != nil {
return "", fmt.Errorf("marshal normalized jsonml: %w", err)
}
return string(out), nil
return stripDocInputUnsafe(string(out)), nil
}
func docEmitJSONMLFixNotes(cmd *cobra.Command, notes []string) {
@@ -0,0 +1,82 @@
// 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 helpers
import "testing"
// TestStripDocInputUnsafe verifies that stripDocInputUnsafe removes exactly the
// characters the server-side RejectControlChars validator rejects (C0 controls
// except tab/newline, DEL, and the dangerous-Unicode set), while leaving all
// legitimate text untouched. Offending codepoints use explicit \u / \x escapes
// so they are unambiguous in source.
func TestStripDocInputUnsafe(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "preserves plain text",
in: "正常的文档内容 with ASCII",
want: "正常的文档内容 with ASCII",
},
{
name: "keeps tab and newline",
in: "标题\n\t正文",
want: "标题\n\t正文",
},
{
name: "drops C0 controls (null, SOH, CR) and DEL",
in: "正文\x00内容\x01段落\x0d结尾\x7f",
want: "正文内容段落结尾",
},
{
name: "drops zero-width space/non-joiner/joiner",
in: "正文\u200b内容\u200c段落\u200d结尾",
want: "正文内容段落结尾",
},
{
name: "drops bidi overrides and isolates",
in: "Bidi\u202a测试\u202e结束\u2066左\u2069右",
want: "Bidi测试结束左右",
},
{
name: "drops line and paragraph separators",
in: "\u2028\u2029行段",
want: "行段",
},
{
name: "drops BOM / ZWNBSP",
in: "BOM\ufeff尾",
want: "BOM尾",
},
{
name: "drops mixed control and dangerous unicode",
in: "混合\x00测试\u200b结尾\x7f",
want: "混合测试结尾",
},
{
name: "empty string stays empty",
in: "",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := stripDocInputUnsafe(tc.in); got != tc.want {
t.Fatalf("stripDocInputUnsafe(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
+161
View File
@@ -0,0 +1,161 @@
// 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 helpers
import (
"strings"
"testing"
)
func TestStripLeadingDuplicateTitleHeading(t *testing.T) {
tests := []struct {
name string
content string
docName string
want string
stripped bool
}{
{
name: "exact match stripped",
content: "# 2026-06-10 Ari晚会简报\n\n聚焦今日变化。\n",
docName: "2026-06-10 Ari晚会简报",
want: "聚焦今日变化。\n",
stripped: true,
},
{
name: "leading blank lines tolerated",
content: "\n\n# Title\nbody",
docName: "Title",
want: "body",
stripped: true,
},
{
name: "case insensitive match",
content: "# weekly REPORT\nbody",
docName: "Weekly Report",
want: "body",
stripped: true,
},
{
name: "atx closing hashes",
content: "# Title #\nbody",
docName: "Title",
want: "body",
stripped: true,
},
{
name: "title-only content becomes empty",
content: "# Title",
docName: "Title",
want: "",
stripped: true,
},
{
name: "different heading kept",
content: "# 背景\nbody",
docName: "2026-06-10 Ari晚会简报",
want: "# 背景\nbody",
stripped: false,
},
{
name: "h2 not touched",
content: "## Title\nbody",
docName: "Title",
want: "## Title\nbody",
stripped: false,
},
{
name: "no heading kept",
content: "plain text\n# Title later",
docName: "Title",
want: "plain text\n# Title later",
stripped: false,
},
{
name: "name ending with hash not over-trimmed",
content: "# C#\nbody",
docName: "C",
want: "# C#\nbody",
stripped: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, stripped := stripLeadingDuplicateTitleHeading(tc.content, tc.docName)
if stripped != tc.stripped {
t.Fatalf("stripped = %v, want %v", stripped, tc.stripped)
}
if got != tc.want {
t.Fatalf("content = %q, want %q", got, tc.want)
}
})
}
}
// TestDocCreateStripsDuplicateTitleHeading verifies the end-to-end behavior:
// `doc create --name X --content "# X\n..."` must not forward the duplicate
// H1 to the MCP tool — the platform renders the document name as the page
// title, so keeping it would display two headings.
func TestDocCreateStripsDuplicateTitleHeading(t *testing.T) {
runner := &docCommandRunner{}
root := newDocTestRoot(runner)
_, errOut, err := executeDocCommand(t, root,
"create", "--name", "2026-06-10 Ari晚会简报",
"--content", "# 2026-06-10 Ari晚会简报\n\n聚焦今日变化。")
if err != nil {
t.Fatalf("execute: %v", err)
}
got, _ := runner.last.Params["markdown"].(string)
if got != "聚焦今日变化。" {
t.Errorf("markdown param = %q, want duplicate H1 stripped", got)
}
if !strings.Contains(errOut, "已自动移除") {
t.Errorf("stderr = %q, want a note about the removed heading", errOut)
}
}
// TestDocCreateKeepsDistinctHeading ensures the guard never eats an H1 that
// differs from the document name.
func TestDocCreateKeepsDistinctHeading(t *testing.T) {
runner := &docCommandRunner{}
root := newDocTestRoot(runner)
_, _, err := executeDocCommand(t, root,
"create", "--name", "晚会简报", "--content", "# 背景\n正文")
if err != nil {
t.Fatalf("execute: %v", err)
}
got, _ := runner.last.Params["markdown"].(string)
if got != "# 背景\n正文" {
t.Errorf("markdown param = %q, want content untouched", got)
}
}
// TestDocCreateTitleOnlyContentOmitsMarkdown: when the body is nothing but
// the duplicate H1, the markdown param should be omitted entirely instead of
// sending an empty string.
func TestDocCreateTitleOnlyContentOmitsMarkdown(t *testing.T) {
runner := &docCommandRunner{}
root := newDocTestRoot(runner)
_, _, err := executeDocCommand(t, root,
"create", "--name", "晚会简报", "--content", "# 晚会简报")
if err != nil {
t.Fatalf("execute: %v", err)
}
if _, ok := runner.last.Params["markdown"]; ok {
t.Errorf("markdown param = %v, want omitted", runner.last.Params["markdown"])
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ import (
)
const (
defaultBaseURL = "https://mcp.dingtalk.com"
defaultBaseURL = config.DefaultMCPBaseURL
registryMetadataKey = "com.dingtalk.mcp.registry/metadata"
// discoveryAPIPath is the path appended to BaseURL when fetching the
+1 -1
View File
@@ -102,7 +102,7 @@ func saveBrowserPolicy(configDir string, policy *BrowserPolicy) error {
}
func ResolveBrowserPolicy(configDir, explicitAgentCode string) (BrowserPolicySelection, error) {
agentCode, err := resolveAgentCode(explicitAgentCode, false)
agentCode, err := resolveAgentCode(explicitAgentCode)
if err != nil {
return BrowserPolicySelection{}, err
}
+28
View File
@@ -16,6 +16,7 @@ package pat
import (
"bytes"
"encoding/json"
"strings"
"testing"
)
@@ -235,3 +236,30 @@ func TestBrowserPolicyCommand_NoAgentCodeWritesDefaultEvenWhenEnvSet(t *testing.
t.Fatalf("len(policy.Agents) = %d, want 0", got)
}
}
func TestBrowserPolicyCommand_RequiresEnabledFlag(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
cmd := newBrowserPolicyCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stdout)
cmd.SetArgs([]string{"--agentCode", "agt-command"})
err := cmd.Execute()
if err == nil {
t.Fatal("browser-policy Execute() error = nil, want missing --enabled error")
}
if !strings.Contains(err.Error(), "--enabled is required") {
t.Fatalf("browser-policy error = %q, want --enabled requirement", err.Error())
}
policy, loadErr := LoadBrowserPolicy(configDir)
if loadErr != nil {
t.Fatalf("LoadBrowserPolicy error = %v", loadErr)
}
if policy.Default != nil || len(policy.Agents) != 0 {
t.Fatalf("policy was modified despite missing --enabled: %#v", policy)
}
}
+179 -51
View File
@@ -25,6 +25,7 @@ import (
"github.com/fatih/color"
"github.com/spf13/cobra"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -58,23 +59,24 @@ func resolveSessionIDFromEnv() string {
return ""
}
// agentCodeEnv is the canonical (and only) environment variable name
// used as a per-shell fallback for the --agentCode flag on `dws pat *`
// commands.
// agentCodeEnv is the canonical environment variable name used as a
// per-shell fallback for the --agentCode flag on `dws pat *` commands.
//
// Why: agent hosts may set their business agent code once when spawning
// a long-lived shell / sub-process. Exposing DINGTALK_DWS_AGENTCODE lets
// the host export the code once and let the CLI resolve it on every pat
// subcommand. The flag always wins when both are set so scripted one-offs
// remain deterministic. When neither flag nor env is set, the request is
// sent without agentCode and lippi-pat-core applies its default agentCode.
// remain deterministic. When neither flag nor env is set, `pat chmod` omits
// agentCode and lets the PAT server apply its open-source default. Batch PAT
// tools receive the resolved agentCode in arguments when present, while the CLI
// also keeps exporting it through env for older gateway paths.
//
// Namespace note: DWS_AGENTCODE / DINGTALK_AGENTCODE / REWIND_AGENTCODE
// are explicitly NOT consumed. The legacy DWS_AGENTCODE alias was
// hard-removed once the public integration surface landed on
// DINGTALK_DWS_AGENTCODE; hosts must migrate rather than rely on a
// silent fallback.
const agentCodeEnv = "DINGTALK_DWS_AGENTCODE"
// Namespace note: keep this as a single-spelled public contract. The reversed
// draft name DWS_DINGTALK_AGENTCODE and legacy names such as DWS_AGENTCODE /
// DINGTALK_AGENTCODE / REWIND_AGENTCODE are explicitly NOT consumed.
const (
agentCodeEnv = authpkg.AgentCodeEnv
)
// agentCodePattern is the validation regex for any --agentCode value
// resolved from either the flag or the agent-code env var. It matches
@@ -84,16 +86,12 @@ const agentCodeEnv = "DINGTALK_DWS_AGENTCODE"
// argument.
var agentCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
// resolveAgentCodeFromEnv returns the fallback agent code from the
// canonical DINGTALK_DWS_AGENTCODE env var. The second return value
// reports the env name that was consumed (for error attribution); it
// is "" when the env is unset or blank. No legacy aliases are honored.
// resolveAgentCodeFromEnv returns the fallback agent code from the canonical
// DINGTALK_DWS_AGENTCODE env var. The second return value reports the env name
// that was consumed (for error attribution); it is "" when the env var is unset
// or blank.
func resolveAgentCodeFromEnv() (string, string) {
primary := strings.TrimSpace(os.Getenv(agentCodeEnv))
if primary != "" {
return primary, agentCodeEnv
}
return "", ""
return authpkg.AgentCodeFromEnv()
}
// validateAgentCode rejects agent codes that would be ambiguous or unsafe
@@ -111,34 +109,34 @@ func validateAgentCode(code string) error {
return nil
}
// resolveAgentCode implements the canonical two-tier lookup for
// --agentCode:
// resolveAgentCode implements the canonical optional lookup for --agentCode:
//
// 1. explicit --agentCode flag value (highest priority; wins over env)
// 2. DINGTALK_DWS_AGENTCODE env var (per-shell primary fallback)
// 3. empty ("") when required=false; typed error when required=true.
// 3. empty ("") so PAT-core can apply its open-source default.
//
// Any non-empty resolved value is validated via validateAgentCode, so
// callers never have to re-validate.
func resolveAgentCode(flagVal string, required bool) (string, error) {
func resolveAgentCode(flagVal string) (string, error) {
code := strings.TrimSpace(flagVal)
envSource := ""
if code == "" {
code, envSource = resolveAgentCodeFromEnv()
}
if code == "" {
if required {
return "", fmt.Errorf(
"flag --agentCode is required (or set env %s)\n hint: dws pat chmod <scope>... --agentCode <id>\n hint: export %s=<id>",
agentCodeEnv, agentCodeEnv)
}
return "", nil
}
if err := validateAgentCode(code); err != nil {
if envSource != "" {
return "", fmt.Errorf("%s env: %w", envSource, err)
return "", apperrors.NewValidation(
fmt.Sprintf("%s env: %v", envSource, err),
apperrors.WithReason("invalid_agent_code"),
)
}
return "", err
return "", apperrors.NewValidation(
err.Error(),
apperrors.WithReason("invalid_agent_code"),
)
}
return code, nil
}
@@ -159,8 +157,25 @@ const (
patBatchUnsupportedCode = "PAT_BATCH_AUTH_UNSUPPORTED"
patBatchUnsupportedCodeLower = "pat_batch_auth_unsupported"
patForgedIdentityCode = "PAT_FORGED_IDENTITY_FIELD"
patForgedIdentityCodeLower = "pat_forged_identity_field"
)
var patBatchMetadataContractCodes = map[string]bool{
"pat_batch_auth_metadata_required": true,
"pat_batch_scope_not_declared": true,
"pat_batch_product_not_declared": true,
}
var patBatchIdentityArgumentKeys = map[string]bool{
"agentCode": true,
"sessionId": true,
"orgId": true,
"uid": true,
"source": true,
"caller": true,
}
var validGrantTypes = map[string]bool{
"once": true,
"session": true,
@@ -189,7 +204,21 @@ scope 格式: <product>.<entity>:<permission>
grantType 规则:
once 一次性,执行一次后自动失效
session 当前会话有效(默认),需要 --session-id
permanent 永久有效`,
permanent 永久有效
批量授权:
dws pat chmod 支持一次传多个 scope 直接批量授予。
也支持 --products / --product 按产品编码批量展开 scope 模板,
--domains / --domain 按产品域批量展开 scope 模板,
--recommend 使用服务端推荐 scope 集合。
使用产品 / 域 / 推荐集合时,CLI 会先生成 batch plan,确认
selected / skipped / pending,再对 selected scopes 执行 batch grant;
--dry-run 只返回授权计划,不写入授权。真正执行批量授权必须显式
添加 --yes;未加 --yes 时 CLI 会阻断并提示 agent 先确认。
agentCode 配置:
可通过 --agentCode 或 DINGTALK_DWS_AGENTCODE
指定;未传 agentCode 时,CLI 会省略该字段并由服务端默认兜底。`,
Args: func(cmd *cobra.Command, args []string) error {
productCodes := collectChmodProductCodes(productFlags, productsFlag, domainFlags, domainsFlag)
if len(args) > 0 || recommend || len(productCodes) > 0 {
@@ -199,12 +228,14 @@ grantType 规则:
},
Example: ` dws pat chmod aitable.record:read --grant-type session --session-id session-xxx
dws pat chmod chat.message:list --grant-type once
dws pat chmod aitable.record:read aitable.record:write --grant-type permanent
dws pat chmod --products calendar,aitable --grant-type session --session-id session-xxx
dws pat chmod --recommend --grant-type session --session-id session-xxx`,
dws pat chmod aitable.record:read aitable.record:write --grant-type permanent --yes
dws pat chmod --product calendar --product aitable --grant-type once --dry-run --format json
dws pat chmod --products calendar,aitable --grant-type session --session-id session-xxx --yes
dws pat chmod --domain calendar --domain chat --grant-type once --yes
dws pat chmod --recommend --grant-type session --session-id session-xxx --yes`,
RunE: func(cmd *cobra.Command, args []string) error {
flagVal, _ := cmd.Flags().GetString("agentCode")
agentCode, err := resolveAgentCode(flagVal, false)
agentCode, err := resolveAgentCode(flagVal)
if err != nil {
return err
}
@@ -239,8 +270,6 @@ grantType 规则:
fmt.Printf("%-16s%s\n", "Tool:", patBatchGrantToolName)
if agentCode != "" {
fmt.Printf("%-16s%s\n", "AgentCode:", agentCode)
} else {
fmt.Printf("%-16s%s\n", "AgentCode:", "(server default)")
}
fmt.Printf("%-16s%v\n", "Scope:", scopes)
fmt.Printf("%-16s%s\n", "GrantType:", grantType)
@@ -267,6 +296,11 @@ grantType 规则:
if len(scopes) == 0 {
return handleToolResult(cmd, c, planResult)
}
if err := requireBatchGrantConfirmation(cmd, true, scopes); err != nil {
return err
}
} else if err := requireBatchGrantConfirmation(cmd, false, scopes); err != nil {
return err
}
batchArgs := map[string]any{
"scopes": scopes,
@@ -315,18 +349,37 @@ grantType 规则:
}
chmodCmd.Flags().String("agentCode", "",
"Agent 唯一标识(可选;不填则由服务端写入默认 AgentCode;env DINGTALK_DWS_AGENTCODE 可注入,flag 优先)")
"Agent 唯一标识(可选;也可通过 env DINGTALK_DWS_AGENTCODE 注入,flag 优先;未传则由服务端默认兜底)")
chmodCmd.Flags().String("grant-type", "session", "授权策略: once|session|permanent")
chmodCmd.Flags().String("session-id", "", "会话标识(session 模式下必填)")
chmodCmd.Flags().StringArrayVar(&productFlags, "product", nil, "产品编码,可重复;与 --products 等价")
chmodCmd.Flags().StringSliceVar(&productsFlag, "products", nil, "产品编码列表,逗号分隔")
chmodCmd.Flags().StringArrayVar(&domainFlags, "domain", nil, "产品域/产品编码,可重复;按产品 scope 模板批量授权")
chmodCmd.Flags().StringSliceVar(&domainsFlag, "domains", nil, "产品域/产品编码列表,逗号分隔")
chmodCmd.Flags().BoolVar(&recommend, "recommend", false, "使用推荐 scope 集合批量授权")
chmodCmd.Flags().StringArrayVar(&productFlags, "product", nil, "产品编码,可重复;与 --products 等价;执行批量授权需 --yes")
chmodCmd.Flags().StringSliceVar(&productsFlag, "products", nil, "产品编码列表,逗号分隔;执行批量授权需 --yes")
chmodCmd.Flags().StringArrayVar(&domainFlags, "domain", nil, "产品域/产品编码,可重复;按产品 scope 模板批量授权;执行授权需 --yes")
chmodCmd.Flags().StringSliceVar(&domainsFlag, "domains", nil, "产品域/产品编码列表,逗号分隔;执行批量授权需 --yes")
chmodCmd.Flags().BoolVar(&recommend, "recommend", false, "使用推荐 scope 集合批量授权;执行授权需 --yes")
return chmodCmd
}
func requireBatchGrantConfirmation(cmd *cobra.Command, usesPlan bool, scopes []string) error {
if !usesPlan && len(scopes) <= 1 {
return nil
}
if commandBoolFlag(cmd, "yes") {
return nil
}
return apperrors.NewValidation(
"batch PAT authorization blocked: explicit user confirmation is required; rerun with --yes only after the user approves the batch grant",
apperrors.WithReason("pat_batch_requires_yes"),
apperrors.WithHint("先执行 dws pat chmod ... --dry-run --format json 查看 selected/skipped/pending;用户明确确认后再追加 --yes 执行批量授权。"),
apperrors.WithActions(
"dws pat chmod <scope1> <scope2> ... --grant-type once --yes",
"dws pat chmod --products <product1,product2> --grant-type once --yes",
"dws pat chmod --recommend --grant-type once --yes",
),
)
}
func collectChmodProductCodes(groups ...[]string) []string {
seen := map[string]bool{}
result := make([]string, 0)
@@ -388,13 +441,11 @@ func callPATBatchGrantWithLegacyFallback(
if c == nil {
return nil, fmt.Errorf("internal error: tool runtime not initialized")
}
result, err := withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
return c.CallTool(ctx, "pat", patBatchGrantToolName, batchArgs)
})
if err == nil && !isPATBatchUnsupportedResult(result) {
result, err := callPATBatchToolWithIdentityFallback(ctx, c, agentCode, sessionID, patBatchGrantToolName, batchArgs)
if err == nil && !isPATBatchFallbackResult(result) {
return result, nil
}
if err != nil && !isPATBatchUnsupportedError(err) && !isToolNotRegisteredError(err) {
if err != nil && !isPATBatchFallbackError(err) && !isToolNotRegisteredError(err) {
return nil, err
}
return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
@@ -414,8 +465,19 @@ func callPATBatchPlan(ctx context.Context, c edition.ToolCaller, agentCode, sess
if c == nil {
return nil, fmt.Errorf("internal error: tool runtime not initialized")
}
return callPATBatchToolWithIdentityFallback(ctx, c, agentCode, sessionID, patBatchPlanToolName, args)
}
func callPATBatchToolWithIdentityFallback(ctx context.Context, c edition.ToolCaller, agentCode, sessionID, toolName string, args map[string]any) (*edition.ToolResult, error) {
result, err := withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
return c.CallTool(ctx, "pat", toolName, args)
})
if !shouldRetryPATBatchWithoutIdentityArgs(result, err, args) {
return result, err
}
compatArgs := cloneWithoutPATIdentityArgs(args)
return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
return c.CallTool(ctx, "pat", patBatchPlanToolName, args)
return c.CallTool(ctx, "pat", toolName, compatArgs)
})
}
@@ -485,6 +547,25 @@ func firstToolResultText(result *edition.ToolResult) string {
}
func isPATBatchUnsupportedResult(result *edition.ToolResult) bool {
return patBatchResultHasCode(result, func(code string) bool {
return strings.EqualFold(code, patBatchUnsupportedCode)
})
}
func isPATBatchFallbackResult(result *edition.ToolResult) bool {
return patBatchResultHasCode(result, func(code string) bool {
normalized := strings.ToLower(strings.TrimSpace(code))
return strings.EqualFold(code, patBatchUnsupportedCode) || patBatchMetadataContractCodes[normalized]
})
}
func isPATForgedIdentityResult(result *edition.ToolResult) bool {
return patBatchResultHasCode(result, func(code string) bool {
return strings.EqualFold(code, patForgedIdentityCode)
})
}
func patBatchResultHasCode(result *edition.ToolResult, matches func(string) bool) bool {
text := firstToolResultText(result)
if text == "" {
return false
@@ -494,7 +575,7 @@ func isPATBatchUnsupportedResult(result *edition.ToolResult) bool {
return false
}
for _, key := range []string{"code", "errorCode", "error_code"} {
if code, ok := body[key].(string); ok && strings.EqualFold(strings.TrimSpace(code), patBatchUnsupportedCode) {
if code, ok := body[key].(string); ok && matches(strings.TrimSpace(code)) {
return true
}
}
@@ -505,6 +586,53 @@ func isPATBatchUnsupportedError(err error) bool {
return err != nil && strings.Contains(normalizedPATErrorText(err), patBatchUnsupportedCodeLower)
}
func isPATBatchFallbackError(err error) bool {
if isPATBatchUnsupportedError(err) {
return true
}
text := normalizedPATErrorText(err)
for code := range patBatchMetadataContractCodes {
if strings.Contains(text, code) {
return true
}
}
return false
}
func isPATForgedIdentityError(err error) bool {
return err != nil && strings.Contains(normalizedPATErrorText(err), patForgedIdentityCodeLower)
}
func shouldRetryPATBatchWithoutIdentityArgs(result *edition.ToolResult, err error, args map[string]any) bool {
if !hasPATIdentityArgs(args) {
return false
}
if err != nil {
return isPATForgedIdentityError(err)
}
return isPATForgedIdentityResult(result)
}
func hasPATIdentityArgs(args map[string]any) bool {
for key := range args {
if patBatchIdentityArgumentKeys[key] {
return true
}
}
return false
}
func cloneWithoutPATIdentityArgs(args map[string]any) map[string]any {
out := make(map[string]any, len(args))
for key, value := range args {
if patBatchIdentityArgumentKeys[key] {
continue
}
out[key] = value
}
return out
}
// callPATToolWithLegacyFallback invokes the canonical PAT grant tool first,
// then silently retries the legacy Chinese alias when the server has not
// registered the canonical tool yet. The retry intentionally emits no stderr
+759 -29
View File
@@ -29,8 +29,8 @@ import (
)
// fakeToolCaller captures the toolArgs passed to CallTool so tests can
// assert how the two-tier --agentCode / DINGTALK_DWS_AGENTCODE / error
// resolver feeds into the outgoing MCP argv.
// assert how the optional --agentCode / DINGTALK_DWS_AGENTCODE resolver feeds
// into the outgoing batch request.
type fakeToolCaller struct {
mu sync.Mutex
dryRun bool
@@ -68,8 +68,11 @@ func (f *fakeToolCaller) Format() string { return "json" }
func (f *fakeToolCaller) DryRun() bool { return f.dryRun }
type recordedToolCall struct {
tool string
args map[string]any
tool string
args map[string]any
agentEnv string
sessionEnv string
dingSessionEnv string
}
type fallbackToolCaller struct {
@@ -198,6 +201,7 @@ func (f *fallbackPATContractErrorToolCaller) DryRun() bool { return false }
type sequenceToolCaller struct {
calls []recordedToolCall
responses []string
errs []error
dryRun bool
}
@@ -207,6 +211,12 @@ func (s *sequenceToolCaller) CallTool(_ context.Context, _ string, toolName stri
copied[k] = v
}
s.calls = append(s.calls, recordedToolCall{tool: toolName, args: copied})
s.calls[len(s.calls)-1].agentEnv = os.Getenv(agentCodeEnv)
s.calls[len(s.calls)-1].sessionEnv = os.Getenv(sessionIDEnvDWS)
s.calls[len(s.calls)-1].dingSessionEnv = os.Getenv(sessionIDEnvDingtalk)
if len(s.errs) >= len(s.calls) && s.errs[len(s.calls)-1] != nil {
return nil, s.errs[len(s.calls)-1]
}
response := `{"success":true,"data":{}}`
if len(s.responses) >= len(s.calls) {
response = s.responses[len(s.calls)-1]
@@ -257,6 +267,37 @@ func buildChmod(t *testing.T, fake *fakeToolCaller) *cobra.Command {
return newChmodCommand(fake)
}
func attachRootYesFlag(t *testing.T, cmd *cobra.Command, yes bool) {
t.Helper()
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("yes", false, "skip confirmation")
root.AddCommand(cmd)
if yes {
if err := root.PersistentFlags().Set("yes", "true"); err != nil {
t.Fatalf("set root --yes: %v", err)
}
}
}
func attachRootPATFlags(t *testing.T, cmd *cobra.Command, yes bool, formatChanged bool) {
t.Helper()
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("yes", false, "skip confirmation")
root.PersistentFlags().String("format", "json", "")
root.PersistentFlags().Bool("verbose", false, "")
root.AddCommand(cmd)
if yes {
if err := root.PersistentFlags().Set("yes", "true"); err != nil {
t.Fatalf("set root --yes: %v", err)
}
}
if formatChanged {
if err := root.PersistentFlags().Set("format", "json"); err != nil {
t.Fatalf("set root --format: %v", err)
}
}
}
func TestRegisterCommands_OnlyExposesChmodForAuthorization(t *testing.T) {
root := &cobra.Command{Use: "dws"}
RegisterCommands(root, &fakeToolCaller{})
@@ -279,6 +320,62 @@ func TestRegisterCommands_OnlyExposesChmodForAuthorization(t *testing.T) {
}
}
func TestPATHelpDocumentsBatchAuthorization(t *testing.T) {
root := &cobra.Command{Use: "dws"}
RegisterCommands(root, &fakeToolCaller{})
patCmd, _, err := root.Find([]string{"pat"})
if err != nil {
t.Fatalf("pat command not found: %v", err)
}
var out strings.Builder
patCmd.SetOut(&out)
patCmd.SetErr(&out)
if err := patCmd.Help(); err != nil {
t.Fatalf("pat help error = %v", err)
}
patHelp := out.String()
for _, want := range []string{
"支持批量授权",
"--products / --product",
"--domains / --domain",
"--recommend",
"DINGTALK_DWS_AGENTCODE",
"未传 agentCode 时由服务端默认兜底",
} {
if !strings.Contains(patHelp, want) {
t.Fatalf("pat help missing %q\nhelp:\n%s", want, patHelp)
}
}
chmodCmd, _, err := root.Find([]string{"pat", "chmod"})
if err != nil {
t.Fatalf("pat chmod command not found: %v", err)
}
out.Reset()
chmodCmd.SetOut(&out)
chmodCmd.SetErr(&out)
if err := chmodCmd.Help(); err != nil {
t.Fatalf("pat chmod help error = %v", err)
}
chmodHelp := out.String()
for _, want := range []string{
"批量授权:",
"一次传多个 scope",
"batch plan",
"--dry-run 只返回授权计划",
"执行批量授权必须显式",
"由服务端默认兜底",
"aitable.record:read aitable.record:write --grant-type permanent --yes",
"dws pat chmod --product calendar --product aitable",
"dws pat chmod --domain calendar --domain chat",
} {
if !strings.Contains(chmodHelp, want) {
t.Fatalf("pat chmod help missing %q\nhelp:\n%s", want, chmodHelp)
}
}
}
func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{responses: []string{
@@ -288,6 +385,7 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("products", "calendar,aitable")
attachRootYesFlag(t, cmd, true)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("chmod RunE error = %v", err)
@@ -305,8 +403,11 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
if got := fake.calls[0].args["recommend"]; got != false {
t.Fatalf("recommend = %#v, want false", got)
}
if got := fake.calls[0].agentEnv; got != "qoderwork" {
t.Fatalf("plan agent env = %q, want qoderwork", got)
}
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
t.Fatalf("plan agentCode = %#v, want qoderwork", got)
t.Fatalf("batch plan agentCode = %#v, want qoderwork", got)
}
if fake.calls[1].tool != patBatchGrantToolName {
t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName)
@@ -314,12 +415,56 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read", "aitable.record:read"}) {
t.Fatalf("grant scopes = %#v, want selected scopes", got)
}
if got := fake.calls[1].agentEnv; got != "qoderwork" {
t.Fatalf("grant agent env = %q, want qoderwork", got)
}
if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
t.Fatalf("grant agentCode = %#v, want qoderwork", got)
t.Fatalf("batch grant agentCode = %#v, want qoderwork", got)
}
}
func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) {
func TestChmod_productsFlagBlocksGrantWithoutYes(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"data":{"selectedScopes":["calendar.event:read","aitable.record:read"]}}`,
}}
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("products", "calendar,aitable")
err := cmd.RunE(cmd, nil)
if err == nil {
t.Fatal("chmod RunE error = nil, want batch --yes blocker")
}
if !strings.Contains(err.Error(), "--yes") || !strings.Contains(err.Error(), "batch PAT authorization blocked") {
t.Fatalf("error = %q, want explicit batch --yes blocker", err.Error())
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool count = %d, want plan only", len(fake.calls))
}
if fake.calls[0].tool != patBatchPlanToolName {
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
}
}
func TestChmod_multipleExplicitScopesBlockWithoutYes(t *testing.T) {
fake := &fakeToolCaller{resultOK: true}
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("grant-type", "once")
err := cmd.RunE(cmd, []string{"aitable.record:read", "aitable.record:write"})
if err == nil {
t.Fatal("chmod RunE error = nil, want batch --yes blocker")
}
if !strings.Contains(err.Error(), "--yes") || !strings.Contains(err.Error(), "batch PAT authorization blocked") {
t.Fatalf("error = %q, want explicit batch --yes blocker", err.Error())
}
if fake.callN != 0 {
t.Fatalf("CallTool was invoked %d times; batch without --yes must not grant", fake.callN)
}
}
func TestChmod_productsSessionModePassesIdentityArgsAndCompatEnv(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"data":{"selectedScopes":["calendar.event:read"]}}`,
@@ -328,6 +473,7 @@ func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) {
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("products", "calendar")
_ = cmd.Flags().Set("session-id", "session-123")
attachRootYesFlag(t, cmd, true)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("chmod RunE error = %v", err)
@@ -339,20 +485,423 @@ func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) {
if got := fake.calls[0].args["grantType"]; got != "session" {
t.Fatalf("plan grantType = %#v, want session", got)
}
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
t.Fatalf("plan agentCode = %#v, want qoderwork", got)
}
if got := fake.calls[0].args["sessionId"]; got != "session-123" {
t.Fatalf("plan sessionId = %#v, want session-123", got)
}
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
t.Fatalf("plan agentCode = %#v, want qoderwork", got)
if got := fake.calls[0].agentEnv; got != "qoderwork" {
t.Fatalf("plan agent env = %q, want qoderwork", got)
}
if fake.calls[0].dingSessionEnv != "session-123" {
t.Fatalf("plan %s env = %q, want session-123", sessionIDEnvDingtalk, fake.calls[0].dingSessionEnv)
}
if got := fake.calls[1].args["grantType"]; got != "session" {
t.Fatalf("grant grantType = %#v, want session", got)
}
if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
t.Fatalf("grant agentCode = %#v, want qoderwork", got)
}
if got := fake.calls[1].args["sessionId"]; got != "session-123" {
t.Fatalf("grant sessionId = %#v, want session-123", got)
}
if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
t.Fatalf("grant agentCode = %#v, want qoderwork", got)
if got := fake.calls[1].agentEnv; got != "qoderwork" {
t.Fatalf("grant agent env = %q, want qoderwork", got)
}
if fake.calls[1].dingSessionEnv != "session-123" {
t.Fatalf("grant %s env = %q, want session-123", sessionIDEnvDingtalk, fake.calls[1].dingSessionEnv)
}
}
func TestChmod_singleScopeReturnsServerAgentCodeInSummary(t *testing.T) {
t.Setenv(agentCodeEnv, "")
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"code":"OK","data":{"agentCode":"dingmbw5n9ktkkbbjv3g","grantType":"once","grantedScopes":["contact.user:get-self"]}}`,
}}
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("grant-type", "once")
attachRootPATFlags(t, cmd, false, false)
output, err := captureStdout(t, func() error {
return cmd.RunE(cmd, []string{"contact.user:get-self"})
})
if err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool count = %d, want 1", len(fake.calls))
}
if fake.calls[0].tool != patBatchGrantToolName {
t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName)
}
if _, ok := fake.calls[0].args["agentCode"]; ok {
t.Fatalf("agentCode arg must be omitted so PAT-core can default it: %#v", fake.calls[0].args)
}
if !strings.Contains(output, "agentCode: dingmbw5n9ktkkbbjv3g") {
t.Fatalf("summary output missing server default agentCode:\n%s", output)
}
}
func TestChmod_flagAgentCodeWinsAndReturnedAgentCodeMatches(t *testing.T) {
t.Setenv(agentCodeEnv, "envshouldlose")
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["chat.bot:search"]}}`,
}}
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("agentCode", "qoderwork")
attachRootPATFlags(t, cmd, false, false)
output, err := captureStdout(t, func() error {
return cmd.RunE(cmd, []string{"chat.bot:search"})
})
if err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool count = %d, want 1", len(fake.calls))
}
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
t.Fatalf("agentCode arg = %#v, want qoderwork", got)
}
if got := fake.calls[0].agentEnv; got != "qoderwork" {
t.Fatalf("%s during CallTool = %q, want qoderwork", agentCodeEnv, got)
}
if !strings.Contains(output, "agentCode: qoderwork") {
t.Fatalf("summary output missing qoderwork agentCode:\n%s", output)
}
}
func TestChmod_batchEntryPointMatrixRequiresYesAndReturnsAgentCode(t *testing.T) {
cases := []struct {
name string
args []string
setFlags func(*cobra.Command)
wantPlanProducts []string
wantRecommend bool
wantCallCount int
}{
{
name: "direct multi scope",
args: []string{"calendar.event:list", "calendar.event:create"},
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("grant-type", "once")
},
wantCallCount: 1,
},
{
name: "product repeated",
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("product", "calendar")
_ = cmd.Flags().Set("product", "aitable")
},
wantPlanProducts: []string{"calendar", "aitable"},
wantCallCount: 2,
},
{
name: "products comma list",
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("products", "calendar,aitable")
},
wantPlanProducts: []string{"calendar", "aitable"},
wantCallCount: 2,
},
{
name: "domain repeated",
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("domain", "calendar")
_ = cmd.Flags().Set("domain", "chat")
},
wantPlanProducts: []string{"calendar", "chat"},
wantCallCount: 2,
},
{
name: "domains comma list",
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("domains", "calendar,chat")
},
wantPlanProducts: []string{"calendar", "chat"},
wantCallCount: 2,
},
{
name: "recommend",
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("recommend", "true")
},
wantRecommend: true,
wantCallCount: 2,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
responses := []string{
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["calendar.event:list","calendar.event:create"]}}`,
}
if tc.wantCallCount == 2 {
responses = []string{
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","selectedScopes":["calendar.event:list","calendar.event:create"],"skippedScopes":[],"pendingScopes":[]}}`,
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["calendar.event:list","calendar.event:create"]}}`,
}
}
fake := &sequenceToolCaller{responses: responses}
cmd := newChmodCommand(fake)
tc.setFlags(cmd)
attachRootPATFlags(t, cmd, true, false)
output, err := captureStdout(t, func() error {
return cmd.RunE(cmd, tc.args)
})
if err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != tc.wantCallCount {
t.Fatalf("CallTool count = %d, want %d", len(fake.calls), tc.wantCallCount)
}
if tc.wantCallCount == 1 {
if fake.calls[0].tool != patBatchGrantToolName {
t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName)
}
if got := fake.calls[0].args["scopes"]; !stringSliceArgEqual(got, tc.args) {
t.Fatalf("grant scopes = %#v, want %#v", got, tc.args)
}
} else {
if fake.calls[0].tool != patBatchPlanToolName {
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
}
if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, tc.wantPlanProducts) {
t.Fatalf("plan productCodes = %#v, want %#v", got, tc.wantPlanProducts)
}
if got := fake.calls[0].args["recommend"]; got != tc.wantRecommend {
t.Fatalf("plan recommend = %#v, want %v", got, tc.wantRecommend)
}
if fake.calls[1].tool != patBatchGrantToolName {
t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName)
}
if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:list", "calendar.event:create"}) {
t.Fatalf("grant scopes = %#v, want selected scopes", got)
}
}
last := fake.calls[len(fake.calls)-1]
if got := last.args["agentCode"]; got != "qoderwork" {
t.Fatalf("grant agentCode = %#v, want qoderwork", got)
}
if !strings.Contains(output, "agentCode: qoderwork") {
t.Fatalf("summary output missing qoderwork agentCode:\n%s", output)
}
})
}
}
func TestChmod_batchPlanEntryPointsDryRunOnlyReturnPlanAgentCode(t *testing.T) {
cases := []struct {
name string
setFlags func(*cobra.Command)
wantPlanProducts []string
wantRecommend bool
}{
{
name: "product",
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("products", "calendar,aitable")
},
wantPlanProducts: []string{"calendar", "aitable"},
},
{
name: "domain",
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("domains", "calendar,chat")
},
wantPlanProducts: []string{"calendar", "chat"},
},
{
name: "recommend",
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("recommend", "true")
},
wantRecommend: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{
dryRun: true,
responses: []string{
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","allGranted":false,"selectedScopes":["calendar.event:list"],"skippedScopes":[],"pendingScopes":[]}}`,
},
}
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("grant-type", "once")
tc.setFlags(cmd)
attachRootPATFlags(t, cmd, false, false)
output, err := captureStdout(t, func() error {
return cmd.RunE(cmd, nil)
})
if err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool count = %d, want dry-run plan only", len(fake.calls))
}
if fake.calls[0].tool != patBatchPlanToolName {
t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
}
if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, tc.wantPlanProducts) {
t.Fatalf("plan productCodes = %#v, want %#v", got, tc.wantPlanProducts)
}
if got := fake.calls[0].args["recommend"]; got != tc.wantRecommend {
t.Fatalf("plan recommend = %#v, want %v", got, tc.wantRecommend)
}
if !strings.Contains(output, "agentCode: qoderwork") || !strings.Contains(output, "selected: 1") {
t.Fatalf("dry-run summary missing plan agentCode/selection:\n%s", output)
}
})
}
}
func TestChmod_batchEntryPointsWithoutYesAreBlocked(t *testing.T) {
cases := []struct {
name string
args []string
setFlags func(*cobra.Command)
wantPlan bool
}{
{
name: "direct multi scope",
args: []string{"calendar.event:list", "calendar.event:create"},
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("grant-type", "once")
},
},
{
name: "product",
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("products", "calendar")
},
wantPlan: true,
},
{
name: "domain",
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("domains", "calendar")
},
wantPlan: true,
},
{
name: "recommend",
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("recommend", "true")
},
wantPlan: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"data":{"selectedScopes":["calendar.event:list","calendar.event:create"]}}`,
}}
cmd := newChmodCommand(fake)
tc.setFlags(cmd)
err := cmd.RunE(cmd, tc.args)
if err == nil {
t.Fatal("chmod RunE error = nil, want batch --yes blocker")
}
if !strings.Contains(err.Error(), "--yes") || !strings.Contains(err.Error(), "batch PAT authorization blocked") {
t.Fatalf("error = %q, want explicit batch --yes blocker", err.Error())
}
if tc.wantPlan {
if len(fake.calls) != 1 || fake.calls[0].tool != patBatchPlanToolName {
t.Fatalf("calls = %#v, want one plan call before blocker", fake.calls)
}
return
}
if len(fake.calls) != 0 {
t.Fatalf("CallTool count = %d, want no MCP calls for direct multi-scope blocker", len(fake.calls))
}
})
}
}
func TestChmod_grantTypeAndSessionParameterMatrix(t *testing.T) {
cases := []struct {
name string
grantType string
sessionFlag string
sessionEnv string
wantSessionID string
wantErr string
}{
{name: "once no session", grantType: "once"},
{name: "permanent no session", grantType: "permanent"},
{name: "session from flag", grantType: "session", sessionFlag: "flag-session", wantSessionID: "flag-session"},
{name: "session from env", grantType: "session", sessionEnv: "env-session", wantSessionID: "env-session"},
{name: "session missing rejected", grantType: "session", wantErr: "--session-id is required"},
{name: "invalid grant type rejected", grantType: "invalid", wantErr: "invalid --grant-type"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
if tc.sessionEnv != "" {
t.Setenv(sessionIDEnvDWS, tc.sessionEnv)
}
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantedScopes":["aitable.record:read"]}}`,
}}
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("grant-type", tc.grantType)
if tc.sessionFlag != "" {
_ = cmd.Flags().Set("session-id", tc.sessionFlag)
}
err := cmd.RunE(cmd, []string{"aitable.record:read"})
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("chmod RunE error = %v, want containing %q", err, tc.wantErr)
}
if len(fake.calls) != 0 {
t.Fatalf("CallTool count = %d, want validator to block before MCP", len(fake.calls))
}
return
}
if err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool count = %d, want 1", len(fake.calls))
}
if got := fake.calls[0].args["grantType"]; got != tc.grantType {
t.Fatalf("grantType arg = %#v, want %s", got, tc.grantType)
}
if tc.wantSessionID == "" {
if _, ok := fake.calls[0].args["sessionId"]; ok {
t.Fatalf("unexpected sessionId arg: %#v", fake.calls[0].args)
}
return
}
if got := fake.calls[0].args["sessionId"]; got != tc.wantSessionID {
t.Fatalf("sessionId arg = %#v, want %s", got, tc.wantSessionID)
}
if got := fake.calls[0].dingSessionEnv; got != tc.wantSessionID {
t.Fatalf("%s during CallTool = %q, want %s", sessionIDEnvDingtalk, got, tc.wantSessionID)
}
})
}
}
@@ -378,11 +927,98 @@ func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) {
if fake.calls[0].tool != patBatchPlanToolName {
t.Fatalf("plan tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
}
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
t.Fatalf("plan agentCode = %#v, want qoderwork", got)
}
if got := fake.calls[0].args["sessionId"]; got != "env-session-123" {
t.Fatalf("plan sessionId = %#v, want env-session-123", got)
}
if got := fake.calls[0].agentEnv; got != "qoderwork" {
t.Fatalf("plan agent env = %q, want qoderwork", got)
}
if fake.calls[0].dingSessionEnv != "env-session-123" {
t.Fatalf("plan %s env = %q, want env-session-123", sessionIDEnvDingtalk, fake.calls[0].dingSessionEnv)
}
}
func TestChmod_batchPlanRetriesWithoutIdentityArgsForCompat(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{
errs: []error{
apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.",
apperrors.WithReason("business_error"),
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
ServerErrorCode: patForgedIdentityCode,
}),
),
nil,
},
responses: []string{
"",
`{"success":true,"data":{"allGranted":true,"selectedScopes":[]}}`,
},
}
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("products", "calendar")
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != 2 {
t.Fatalf("CallTool count = %d, want 2", len(fake.calls))
}
if fake.calls[0].tool != patBatchPlanToolName || fake.calls[1].tool != patBatchPlanToolName {
t.Fatalf("tools = %q, %q; want repeated %q", fake.calls[0].tool, fake.calls[1].tool, patBatchPlanToolName)
}
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
t.Fatalf("plan agentCode = %#v, want qoderwork", got)
t.Fatalf("first plan agentCode = %#v, want qoderwork", got)
}
if _, ok := fake.calls[1].args["agentCode"]; ok {
t.Fatalf("compat retry must omit agentCode arg: %#v", fake.calls[1].args)
}
if got := fake.calls[1].agentEnv; got != "qoderwork" {
t.Fatalf("compat retry %s = %q, want qoderwork", agentCodeEnv, got)
}
}
func TestChmod_batchGrantRetriesWithoutIdentityArgsForCompat(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{
errs: []error{
apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.",
apperrors.WithReason("business_error"),
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
ServerErrorCode: patForgedIdentityCode,
}),
),
nil,
},
responses: []string{
"",
`{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`,
},
}
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("grant-type", "once")
if err := cmd.RunE(cmd, []string{"calendar.event:read"}); err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != 2 {
t.Fatalf("CallTool count = %d, want 2", len(fake.calls))
}
if fake.calls[0].tool != patBatchGrantToolName || fake.calls[1].tool != patBatchGrantToolName {
t.Fatalf("tools = %q, %q; want repeated %q", fake.calls[0].tool, fake.calls[1].tool, patBatchGrantToolName)
}
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
t.Fatalf("first grant agentCode = %#v, want qoderwork", got)
}
if _, ok := fake.calls[1].args["agentCode"]; ok {
t.Fatalf("compat retry must omit agentCode arg: %#v", fake.calls[1].args)
}
if got := fake.calls[1].agentEnv; got != "qoderwork" {
t.Fatalf("compat retry %s = %q, want qoderwork", agentCodeEnv, got)
}
}
@@ -407,6 +1043,7 @@ func TestResolveSessionIDFromEnvMatchesHeaderPriority(t *testing.T) {
}
func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
t.Setenv(sessionIDEnvDingtalk, "ding-session-123")
fake := &fakeToolCaller{resultOK: true}
@@ -416,6 +1053,9 @@ func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) {
t.Fatalf("chmod RunE error = %v", err)
}
if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
t.Fatalf("agentCode arg = %#v, want qoderwork", got)
}
if got := fake.gotArgs["sessionId"]; got != "ding-session-123" {
t.Fatalf("sessionId arg = %#v, want ding-session-123", got)
}
@@ -428,6 +1068,7 @@ func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) {
}
func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
t.Setenv(sessionIDEnvDingtalk, "stale-session")
fake := &fakeToolCaller{resultOK: true}
@@ -438,6 +1079,9 @@ func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) {
t.Fatalf("chmod RunE error = %v", err)
}
if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
t.Fatalf("agentCode arg = %#v, want qoderwork", got)
}
if got := fake.gotArgs["sessionId"]; got != "flag-session" {
t.Fatalf("sessionId arg = %#v, want flag-session", got)
}
@@ -458,6 +1102,7 @@ func TestChmod_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T)
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("recommend", "true")
attachRootYesFlag(t, cmd, true)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("chmod RunE error = %v", err)
@@ -523,7 +1168,7 @@ func TestChmod_explicitScopesDryRunShowsBatchGrantTool(t *testing.T) {
// TestChmod_agentCode_env_fallback verifies that when --agentCode is
// omitted but DINGTALK_DWS_AGENTCODE is exported, the resolver picks
// the env value up and forwards it verbatim in the MCP argv.
// the env value up for both batch arguments and gateway-compatible env.
func TestChmod_agentCode_env_fallback(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
@@ -543,7 +1188,7 @@ func TestChmod_agentCode_env_fallback(t *testing.T) {
t.Fatalf("agent env = %q, want %q", got, "qoderwork")
}
if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
t.Fatalf("batch argv agentCode = %#v, want qoderwork", got)
t.Fatalf("batch agentCode = %#v, want qoderwork", got)
}
if got := fake.gotArgs["scopes"]; !stringSliceArgEqual(got, []string{"aitable.record:read"}) {
t.Fatalf("scopes in argv = %#v, want %#v", got, []string{"aitable.record:read"})
@@ -553,8 +1198,9 @@ func TestChmod_agentCode_env_fallback(t *testing.T) {
}
}
func TestChmod_withoutAgentCodeUsesServerDefault(t *testing.T) {
func TestChmod_agentCode_reversedEnvIgnored(t *testing.T) {
t.Setenv(agentCodeEnv, "")
t.Setenv("DWS_DINGTALK_AGENTCODE", "compatwork")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
@@ -566,14 +1212,35 @@ func TestChmod_withoutAgentCodeUsesServerDefault(t *testing.T) {
if fake.gotTool != patBatchGrantToolName {
t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName)
}
if _, ok := fake.gotArgs["agentCode"]; ok {
t.Fatalf("agentCode arg must be omitted; reversed env name must not be consumed: %#v", fake.gotArgs)
}
if got := fake.gotAgentEnv; got != "" {
t.Fatalf("agent env = %q, want empty so server default agentCode is used", got)
t.Fatalf("%s during CallTool = %q, want empty because reversed env is ignored", agentCodeEnv, got)
}
}
func TestChmod_withoutAgentCodeLetsServerDefault(t *testing.T) {
t.Setenv(agentCodeEnv, "")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
_ = cmd.Flags().Set("grant-type", "once")
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
t.Fatalf("chmod RunE error = %v, want server-side default agentCode path", err)
}
if fake.callN != 1 {
t.Fatalf("CallTool was invoked %d times; missing agentCode must still reach the batch caller", fake.callN)
}
if fake.gotTool != patBatchGrantToolName {
t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName)
}
if _, ok := fake.gotArgs["agentCode"]; ok {
t.Fatalf("batch argv must omit agentCode when caller leaves it unset: %#v", fake.gotArgs)
t.Fatalf("agentCode arg must be omitted for server default path: %#v", fake.gotArgs)
}
if got := fake.gotArgs["scopes"]; !stringSliceArgEqual(got, []string{"aitable.record:read"}) {
t.Fatalf("scopes in argv = %#v, want %#v", got, []string{"aitable.record:read"})
if got := fake.gotAgentEnv; got != "" {
t.Fatalf("%s during CallTool = %q, want empty for server default path", agentCodeEnv, got)
}
}
@@ -786,6 +1453,40 @@ func TestCallPATToolWithLegacyFallback_patContractErrorDoesNotRetryLegacyAlias(t
}
}
func TestChmod_batchMetadataScopeErrorFallsBackToPATGrant(t *testing.T) {
fake := &sequenceToolCaller{
responses: []string{
`{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED","data":{"scopes":["mail:send"]}}`,
`{"success":true,"data":{"authRequestId":"req-ok"}}`,
},
}
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("agentCode", "qoderwork")
_ = cmd.Flags().Set("grant-type", "once")
if err := cmd.RunE(cmd, []string{"mail:send"}); err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != 2 {
t.Fatalf("CallTool call count = %d, want 2", len(fake.calls))
}
if fake.calls[0].tool != patBatchGrantToolName {
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName)
}
if fake.calls[1].tool != patGrantToolName {
t.Fatalf("fallback tool = %q, want %q", fake.calls[1].tool, patGrantToolName)
}
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
t.Fatalf("batch agentCode = %#v, want qoderwork", got)
}
if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
t.Fatalf("fallback agentCode = %#v, want qoderwork", got)
}
if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"mail:send"}) {
t.Fatalf("fallback scopes = %#v, want mail:send", got)
}
}
func TestIsToolNotRegisteredError_ChineseGatewayMessage(t *testing.T) {
err := errors.New("pat chmod failed: business error: PARAM_ERROR - 未找到指定工具")
if !isToolNotRegisteredError(err) {
@@ -813,6 +1514,13 @@ func TestIsPATBatchUnsupportedResultCaseInsensitive(t *testing.T) {
}
}
func TestIsPATBatchFallbackResultIncludesMetadataContractErrors(t *testing.T) {
result := &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED"}`}}}
if !isPATBatchFallbackResult(result) {
t.Fatal("isPATBatchFallbackResult() = false, want true")
}
}
func TestIsPATBatchUnsupportedErrorUsesNormalizedDiagnostics(t *testing.T) {
err := apperrors.NewAPI("business error: success=false",
apperrors.WithReason("business_error"),
@@ -825,6 +1533,18 @@ func TestIsPATBatchUnsupportedErrorUsesNormalizedDiagnostics(t *testing.T) {
}
}
func TestIsPATBatchFallbackErrorIncludesMetadataContractDiagnostics(t *testing.T) {
err := apperrors.NewAPI("business error: success=false",
apperrors.WithReason("business_error"),
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
ServerErrorCode: "PAT_BATCH_PRODUCT_NOT_DECLARED",
}),
)
if !isPATBatchFallbackError(err) {
t.Fatal("isPATBatchFallbackError() = false, want true")
}
}
func TestHandleToolResult_emptyResultReturnsError(t *testing.T) {
err := handleToolResult(nil, nil, &edition.ToolResult{})
if err == nil {
@@ -978,34 +1698,35 @@ func TestChmod_agentCode_flag_wins_over_env(t *testing.T) {
t.Fatalf("agent env = %q, want %q (flag must win over env)", got, "flagval")
}
if got := fake.gotArgs["agentCode"]; got != "flagval" {
t.Fatalf("batch argv agentCode = %#v, want flagval", got)
t.Fatalf("batch agentCode = %#v, want flagval", got)
}
}
// TestChmod_agentCode_legacy_env_not_recognized is a reverse-guard: after
// the SSOT hard-removal of the DWS_AGENTCODE alias, exporting only the
// legacy env MUST NOT be consumed. The command is still allowed to run,
// omits agentCode, and lets lippi-pat-core write its default agentCode.
// TestChmod_agentCode_legacy_env_not_recognized is a reverse-guard: only
// DINGTALK_DWS_AGENTCODE is consumed as the env fallback. Legacy / draft names
// MUST NOT be consumed as agentCode. The request is still sent so PAT-core can
// apply its open-source default.
func TestChmod_agentCode_legacy_env_not_recognized(t *testing.T) {
t.Setenv(agentCodeEnv, "")
t.Setenv("DWS_AGENTCODE", "legacyval")
t.Setenv("DWS_DINGTALK_AGENTCODE", "draftval")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
_ = cmd.Flags().Set("grant-type", "once")
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
t.Fatalf("chmod RunE error = %v", err)
t.Fatalf("chmod RunE error = %v, want server-side default agentCode path", err)
}
if fake.callN != 1 {
t.Fatalf("CallTool was invoked %d times, want 1", fake.callN)
t.Fatalf("CallTool was invoked %d times; legacy env should be ignored but request should continue", fake.callN)
}
if _, ok := fake.gotArgs["agentCode"]; ok {
t.Fatalf("agentCode arg must be omitted; legacy DWS_AGENTCODE must not be consumed: %#v", fake.gotArgs)
}
if got := fake.gotAgentEnv; got != "" {
t.Fatalf("agent env = %q, want empty; legacy DWS_AGENTCODE must not be consumed", got)
}
if _, ok := fake.gotArgs["agentCode"]; ok {
t.Fatalf("batch argv must omit agentCode when only legacy env is set: %#v", fake.gotArgs)
}
}
// ---------------------------------------------------------------------------
@@ -1046,8 +1767,17 @@ func TestResolveAgentCodeFromEnv(t *testing.T) {
code, src, "qoderwork", agentCodeEnv)
}
// Reverse-guard: the draft reversed spelling is intentionally ignored.
t.Setenv(agentCodeEnv, "")
t.Setenv("DWS_DINGTALK_AGENTCODE", "compatwork")
if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" {
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty — DWS_DINGTALK_AGENTCODE must be ignored",
code, src)
}
// Empty primary → ("", "").
t.Setenv(agentCodeEnv, "")
t.Setenv("DWS_DINGTALK_AGENTCODE", "")
if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" {
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty", code, src)
}
+10 -1
View File
@@ -36,8 +36,17 @@ func RegisterCommands(root *cobra.Command, c edition.ToolCaller) {
能力说明:
pat chmod 默认输出轻量授权摘要;显式 --format json / --verbose 时,
才返回服务端完整 JSON(含逐 scope 明细),便于机器校验。
pat chmod 支持批量授权:可一次传多个 scope,也可通过
--products / --product、--domains / --domain 或 --recommend
让服务端按产品模板 / 推荐集合计算授权计划,再批量授予选中的 scope。
批量计划会返回 selected / skipped / pending 明细;--dry-run 只预览计划,
不写入授权。真正执行批量授权前必须由用户显式添加 --yes;未加 --yes
时 CLI 会阻断并提示 agent 先确认。
浏览器是否打开由本地 PAT 策略单独决定,与 json / non-json 独立。
生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。
pat chmod 可传 --agentCode,或设置 DINGTALK_DWS_AGENTCODE;
CLI 会把显式 agentCode 放入 batch 请求参数,
并同步注入 gateway 兼容身份头。未传 agentCode 时由服务端默认兜底。
浏览器策略生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。
写入 agent 策略需显式传 --agentCode;不传则写入全局默认策略。
Host-owned PAT 开关:
+27 -3
View File
@@ -494,8 +494,7 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
}
func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte) (*http.Response, error) {
// Strip any query/fragment from the endpoint to prevent parameter injection.
endpoint = validate.StripQueryFragment(endpoint)
endpoint = sanitizeJSONRPCEndpoint(endpoint)
var lastErr error
for attempt := 0; attempt <= c.MaxRetries; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
@@ -533,7 +532,7 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte)
// Diagnostic: log identity-related headers on first attempt.
if attempt == 0 && c.FileLogger != nil {
c.FileLogger.LogAttrs(context.Background(), slog.LevelDebug, "http_request_headers",
slog.String("endpoint", endpoint),
slog.String("endpoint", RedactURL(endpoint)),
slog.String("x-user-access-token-present", fmt.Sprintf("%t", req.Header.Get("x-user-access-token") != "")),
slog.Int("extra_headers_count", len(c.ExtraHeaders)),
)
@@ -593,6 +592,31 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte)
)
}
func sanitizeJSONRPCEndpoint(endpoint string) string {
parsed, err := url.Parse(strings.TrimSpace(endpoint))
if err != nil || parsed.Host == "" {
return validate.StripQueryFragment(endpoint)
}
parsed.Fragment = ""
if shouldPreserveEndpointQuery(parsed) {
return parsed.String()
}
parsed.RawQuery = ""
return parsed.String()
}
func shouldPreserveEndpointQuery(parsed *url.URL) bool {
if parsed == nil || !strings.EqualFold(parsed.Scheme, "https") {
return false
}
switch strings.ToLower(parsed.Hostname()) {
case "mcp-gw.dingtalk.com", "pre-mcp-gw.dingtalk.com":
return true
default:
return false
}
}
func retryable(statusCode int) bool {
return statusCode == http.StatusTooManyRequests || statusCode >= http.StatusInternalServerError
}
+38
View File
@@ -2,6 +2,9 @@ package transport
import (
"bytes"
"context"
"io"
"log/slog"
"net/http"
"strings"
"testing"
@@ -83,6 +86,41 @@ func TestRedactURL(t *testing.T) {
}
}
func TestDoWithRetryRedactsGatewayQueryInHeaderDebugLog(t *testing.T) {
t.Parallel()
var logBuf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))
var requestedURL string
client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requestedURL = req.URL.String()
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{}}`)),
Request: req,
}, nil
})})
client.FileLogger = logger
resp, err := client.doWithRetry(context.Background(), "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag", []byte(`{}`))
if err != nil {
t.Fatalf("doWithRetry() error = %v", err)
}
defer resp.Body.Close()
if !strings.Contains(requestedURL, "key=secret") {
t.Fatalf("gateway request URL = %q, want preserved key query", requestedURL)
}
out := logBuf.String()
if strings.Contains(out, "key=secret") || strings.Contains(out, "secret") {
t.Fatalf("debug log leaked gateway key: %s", out)
}
if !strings.Contains(out, "key=REDACTED") {
t.Fatalf("debug log did not include redacted endpoint, got: %s", out)
}
}
func TestSanitizeBearerToken(t *testing.T) {
t.Parallel()
tests := []struct {
+50
View File
@@ -507,6 +507,56 @@ func TestCallToolClassifiesJSONRPCInvalidParamsAsValidationError(t *testing.T) {
}
}
func TestSanitizeJSONRPCEndpointPreservesDingTalkMCPGatewayQuery(t *testing.T) {
t.Parallel()
cases := []struct {
name string
endpoint string
want string
}{
{
name: "prepub gateway",
endpoint: "https://pre-mcp-gw.dingtalk.com/server/demo?key=secret#frag",
want: "https://pre-mcp-gw.dingtalk.com/server/demo?key=secret",
},
{
name: "prod gateway",
endpoint: "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag",
want: "https://mcp-gw.dingtalk.com/server/demo?key=secret",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := sanitizeJSONRPCEndpoint(tt.endpoint); got != tt.want {
t.Fatalf("sanitizeJSONRPCEndpoint() = %q, want %q", got, tt.want)
}
})
}
}
func TestSanitizeJSONRPCEndpointStripsQueryForOtherHosts(t *testing.T) {
t.Parallel()
got := sanitizeJSONRPCEndpoint("https://example.com/server/demo?admin=true#frag")
want := "https://example.com/server/demo"
if got != want {
t.Fatalf("sanitizeJSONRPCEndpoint() = %q, want %q", got, want)
}
}
func TestSanitizeJSONRPCEndpointStripsQueryForHTTPGateway(t *testing.T) {
t.Parallel()
got := sanitizeJSONRPCEndpoint("http://pre-mcp-gw.dingtalk.com/server/demo?key=secret#frag")
want := "http://pre-mcp-gw.dingtalk.com/server/demo"
if got != want {
t.Fatalf("sanitizeJSONRPCEndpoint() = %q, want %q", got, want)
}
}
type testSnapshotRecorder struct {
root string
}
+14
View File
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
@@ -194,6 +195,19 @@ func TestDefaultFetchServersLimit(t *testing.T) {
}
}
func TestGetMCPBaseURLDefaultsToProduction(t *testing.T) {
dir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", dir)
got := GetMCPBaseURL()
if got != "https://mcp.dingtalk.com" {
t.Fatalf("GetMCPBaseURL() = %q, want production MCP URL", got)
}
if strings.Contains(got, "pre-mcp") {
t.Fatalf("GetMCPBaseURL() = %q, must not default to prepub MCP URL", got)
}
}
func TestGetMCPBaseURLUsesConfigFile(t *testing.T) {
dir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", dir)
+17
View File
@@ -68,6 +68,12 @@ type Hooks struct {
Name string // "open" (default) / overlay identifier
ScenarioCode string // injected into x-dingtalk-scenario-code header
// ClawTypeValue is the claw identity carried in message-send tool
// arguments (parameter clawType) so the IM server can render the
// "Send from AI" indicator on delivered messages. Empty → falls back
// to DefaultOSSClawType; overlays set their own value (e.g. "wukong").
ClawTypeValue string
// --- runtime mode ---
IsEmbedded bool // true when running inside a host application
HideAuthLogin bool // true suppresses the "dws auth login" command
@@ -165,3 +171,14 @@ func Override(h *Hooks) {
defer mu.Unlock()
current = h
}
// ClawType returns the claw identity for the active edition, falling back
// to DefaultOSSClawType when the overlay does not set one. Message-send
// helpers attach this value as the clawType tool argument so the IM server
// can label delivered messages as sent via AI.
func ClawType() string {
if v := Get().ClawTypeValue; v != "" {
return v
}
return DefaultOSSClawType
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package edition
import "testing"
func TestClawTypeDefaultsToOSSValue(t *testing.T) {
prev := Get()
defer Override(prev)
Override(defaultHooks())
if got := ClawType(); got != DefaultOSSClawType {
t.Fatalf("ClawType() = %q, want %q", got, DefaultOSSClawType)
}
}
func TestClawTypeUsesOverlayValue(t *testing.T) {
prev := Get()
defer Override(prev)
Override(&Hooks{Name: "overlay", ClawTypeValue: "wukong"})
if got := ClawType(); got != "wukong" {
t.Fatalf("ClawType() = %q, want overlay value %q", got, "wukong")
}
}
+4 -2
View File
@@ -43,10 +43,12 @@
### 2. devdoc — 开发文档搜索
**用 `devdoc` 的场景**:
- "API 调用报错 403 怎么解决" — 开发调试问题
- "API 调用报错 403 怎么解决" — 走 `devdoc error diagnose`
- "requestId 15r6h45w0muec 为什么失败" — 走 `devdoc error diagnose --request-id ...`
- "搜一下 OAuth2 接入文档" — 开放平台技术文档
- "CLI 命令出错了怎么办" — CLI 使用错误
- 用户提到"开发"、"API"、"调用错误"
- 用户提到"开发"、"API"、"接口文档" → `devdoc article search`
- 用户提到"调用错误"、"错误码"、"requestId"、"traceId" → `devdoc error diagnose`
---
+37
View File
@@ -20,14 +20,41 @@ Flags:
--size int 分页大小 (默认 10)
```
### 错误排查
```
Usage:
dws devdoc error diagnose [flags]
dws devdoc error troubleshoot [flags]
Example:
dws devdoc error diagnose --request-id 15r6h45w0muec
dws devdoc error diagnose --trace-id 15r6h45w0muec --api "创建日程"
dws devdoc error diagnose --error-code 33012 --error-message "missing scope"
dws devdoc error diagnose --query "机器人回调失败" --context "HTTP 403"
Flags:
--query string 原始排查问题
--request-id string 开放平台 requestId
--trace-id string requestId 的兼容别名
--error-code string 错误码
--error-message string 错误描述,会合并进原始问题
--api string API 名称,会合并进原始问题作为补充检索词
--context string 额外排查上下文,会合并进原始问题
--page int 分页页码 (从 1 开始,默认 1)
--size int 分页大小 (默认 10)
```
## 意图判断
用户问开放平台 API / 字段 / 错误码 / SDK / 鉴权 / 回调 / 配额相关的技术细节:
- 走 `devdoc article search`,把用户问的关键短语作为位置参数或 `--query`
用户已经提供 requestId / traceId / 错误码 / 错误描述 / 失败上下文:
- 走 `devdoc error diagnose`,优先传 `--request-id`,没有 requestId 时传 `--error-code`、`--error-message`、`--query` 或 `--context`
关键区分:
- devdoc(钉钉**开放平台**开发者文档,面向研发) vs doc(钉钉在线文档,面向普通用户内容)
- devdoc 只做搜索,不做读取;命中条目返回标题、摘要、文档链接,由 Agent 引用链接或进一步浏览
- `devdoc error diagnose` 只返回诊断事实、参考资料和链接,不生成 AI 分析结论
- `--api`、`--error-message`、`--context` 是 CLI 侧易用参数,调用 MCP 时会合并到 `query`;MCP 入参只发送 `query`、`requestId`、`errorCode`、`page`、`size`
## 核心工作流
@@ -43,11 +70,21 @@ dws devdoc article search --query "消息卡片" --page 2 --size 5 --format json
# 查错误码 / 字段含义
dws devdoc article search --query "errcode 40078" --format json
# 已经有 requestId 时排查
dws devdoc error diagnose --request-id 15r6h45w0muec --format json
# 只有 traceId 时按 requestId 兼容处理
dws devdoc error diagnose --trace-id 15r6h45w0muec --api "创建日程" --format json
# 只有错误码和错误描述时排查
dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json
```
## 注意事项
- 关键词必填;可用位置参数、`--query` 或兼容别名 `--keyword`。建议传用户原话里的关键名词(API 名、错误码、能力名),不要过度改写
- 错误排查至少提供 `--query`、`--request-id`、`--error-code`、`--error-message`、`--context` 之一;单独 `--api` 只作为补充上下文,不足以发起排查
- 返回按相关性排序,默认 `--size 10`;要拿更多结果时先翻页,再考虑换关键词
- 命中结果里的链接是钉钉开放平台公开文档,可直接给用户做参考
- 不要把 devdoc 用来查业务数据(那是 aitable / doc / report 的事);devdoc 只查**官方开发者文档**
@@ -41,7 +41,7 @@ Flags:
## 关键说明
- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。
- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。若正文首行仍是与 `--name` 相同的一级标题,CLI 会自动移除并在 stderr 提示(仅精确匹配会被移除,其他一级标题不受影响)。
- 不传 `--folder` 和 `--workspace` 时,默认创建在「我的文档」根目录。
- `--folder` 仅接受文档文件夹 `nodeId` / `dentryUuid` / alidocs 文件夹 URL;**禁止**传入 drive `dentryId`、`parentId`、`spaceId` 这类纯数字 ID。
- 输入方式选择见 [`./doc-update.md` §内容写入管道](./doc-update.md#内容写入管道createupdate-共用)(与 update 共用)。短文本字面量可 `--content`,多行/表格/特殊字符必须 `--content-file` 或 `--content -`。
@@ -45,7 +45,7 @@
| 项目 | 要求 |
|------|------|
| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题 |
| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题(若重复,CLI 会自动移除与 `--name` 相同的首行 H1 并提示) |
| 位置 | 默认创建到我的文档;指定目录时只接受文档文件夹 `nodeId` 或 alidocs 文件夹 URL |
| 正文 | 多行、表格、代码块、特殊字符或长度 >= 2KB 时必须写入 UTF-8 临时 `.md` 文件 |
| 格式 | 按 §JSONML 起稿判定 决定起稿路径:命中 JSONML 起稿条件时**直接用 JSONML 构造**(跳过 markdown);未命中时用 Markdown 起稿,创建后按 [doc-update-workflow.md](./doc-update-workflow.md) 精修 |
@@ -147,7 +147,7 @@ dws doc read --node <nodeId> --content-format jsonml --output /tmp/<name>-readba
- 只使用用户已提供或对话中已确认的正文素材。
- 如果正文素材不足,先补齐文档目标、受众、章节和缺口;不要在本文中临时扩展跨产品采集流程。
- **先按 [doc-style-guideline.md §2.0 类型判断决策表](./doc-style-guideline.md) 确定文档类型,再用对应类型的骨架样板(§2.1 决策型 / §2.2 执行型 / §2.3 说明型 / §2.4 知识沉淀型)**。不要套通用三段式。
- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。
- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。与 `--name` 相同的首行一级标题会被 CLI 自动移除(stderr 有提示),但不要依赖这个兜底。
- 摘要、bullet、引用块、callout 等元素的使用边界以 style-guideline §3-§7 为准。
- 同类信息保持一致:风险、状态、行动项各用一种元素 + 一种视觉语义(style-guideline §1.2 / §5)。
- 临时文件必须保留真实换行,不能把换行写成字面量 `\n`。
+21
View File
@@ -18,6 +18,25 @@ Flags:
--size string 每页数量 (默认 10)
```
### 错误排查
```
Usage:
dws devdoc error diagnose [flags]
Example:
dws devdoc error diagnose --request-id 15r6h45w0muec --format json
dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json
Flags:
--query string 原始排查问题
--request-id string 开放平台 requestId
--trace-id string requestId 的兼容别名
--error-code string 错误码
--error-message string 错误描述,会合并进原始问题
--api string API 名称,会合并进原始问题作为补充检索词
--context string 额外排查上下文,会合并进原始问题
--page int 分页页码 (默认 1)
--size int 分页大小 (默认 10)
```
---
## oa — 审批
@@ -99,6 +118,7 @@ Example:
## 意图判断
- 用户说"开发文档/API 文档/接口文档" → `devdoc article search`
- 用户说"调用报错/requestId/traceId/错误码/错误描述" → `devdoc error diagnose`
- 用户说"审批/请假/报销/出差" → `oa approval`
- 用户说"同意审批/批准" → `oa approval approve`
- 用户说"拒绝审批/驳回" → `oa approval reject`
@@ -111,6 +131,7 @@ Example:
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `devdoc article search` | 文档链接 | 直接展示给用户 |
| `devdoc error diagnose` | diagnosticInfo、references、materials | 排查开放平台调用错误 |
| `oa approval list-forms` | processCode | detail / records 等 |
| `oa approval tasks` | taskId, instanceId | approve / reject |
| `oa approval list-pending` | instanceId | detail / approve / reject |
+3 -1
View File
@@ -28,7 +28,9 @@ metadata:
| 用户说 | 命令 |
|--------|------|
| "查 OAuth2 接入文档" | `dws devdoc article search --query "OAuth2 接入"` |
| "API 调用报错怎么办" | `dws devdoc article search --query "<报错关键词>"` |
| "API 调用报错怎么办" | `dws devdoc error diagnose --query "<报错关键词>"` |
| "requestId 15r6h45w0muec 为什么失败" | `dws devdoc error diagnose --request-id 15r6h45w0muec` |
| "错误码 33012" | `dws devdoc error diagnose --error-code 33012` |
| "开放接口文档" | `dws devdoc article search --query "<接口名或场景>"` |
## 跨产品协作
@@ -20,14 +20,41 @@ Flags:
--size int 分页大小 (默认 10)
```
### 错误排查
```
Usage:
dws devdoc error diagnose [flags]
dws devdoc error troubleshoot [flags]
Example:
dws devdoc error diagnose --request-id 15r6h45w0muec
dws devdoc error diagnose --trace-id 15r6h45w0muec --api "创建日程"
dws devdoc error diagnose --error-code 33012 --error-message "missing scope"
dws devdoc error diagnose --query "机器人回调失败" --context "HTTP 403"
Flags:
--query string 原始排查问题
--request-id string 开放平台 requestId
--trace-id string requestId 的兼容别名
--error-code string 错误码
--error-message string 错误描述,会合并进原始问题
--api string API 名称,会合并进原始问题作为补充检索词
--context string 额外排查上下文,会合并进原始问题
--page int 分页页码 (从 1 开始,默认 1)
--size int 分页大小 (默认 10)
```
## 意图判断
用户问开放平台 API / 字段 / 错误码 / SDK / 鉴权 / 回调 / 配额相关的技术细节:
- 走 `devdoc article search`,把用户问的关键短语作为位置参数或 `--query`
用户已经提供 requestId / traceId / 错误码 / 错误描述 / 失败上下文:
- 走 `devdoc error diagnose`,优先传 `--request-id`,没有 requestId 时传 `--error-code`、`--error-message`、`--query` 或 `--context`
关键区分:
- devdoc(钉钉**开放平台**开发者文档,面向研发) vs doc(钉钉在线文档,面向普通用户内容)
- devdoc 只做搜索,不做读取;命中条目返回标题、摘要、文档链接,由 Agent 引用链接或进一步浏览
- `devdoc error diagnose` 只返回诊断事实、参考资料和链接,不生成 AI 分析结论
- `--api`、`--error-message`、`--context` 是 CLI 侧易用参数,调用 MCP 时会合并到 `query`;MCP 入参只发送 `query`、`requestId`、`errorCode`、`page`、`size`
## 核心工作流
@@ -43,11 +70,21 @@ dws devdoc article search --query "消息卡片" --page 2 --size 5 --format json
# 查错误码 / 字段含义
dws devdoc article search --query "errcode 40078" --format json
# 已经有 requestId 时排查
dws devdoc error diagnose --request-id 15r6h45w0muec --format json
# 只有 traceId 时按 requestId 兼容处理
dws devdoc error diagnose --trace-id 15r6h45w0muec --api "创建日程" --format json
# 只有错误码和错误描述时排查
dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json
```
## 注意事项
- 关键词必填;可用位置参数、`--query` 或兼容别名 `--keyword`。建议传用户原话里的关键名词(API 名、错误码、能力名),不要过度改写
- 错误排查至少提供 `--query`、`--request-id`、`--error-code`、`--error-message`、`--context` 之一;单独 `--api` 只作为补充上下文,不足以发起排查
- 返回按相关性排序,默认 `--size 10`;要拿更多结果时先翻页,再考虑换关键词
- 命中结果里的链接是钉钉开放平台公开文档,可直接给用户做参考
- 不要把 devdoc 用来查业务数据(那是 aitable / doc / report 的事);devdoc 只查**官方开发者文档**
@@ -41,7 +41,7 @@ Flags:
## 关键说明
- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。
- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。若正文首行仍是与 `--name` 相同的一级标题,CLI 会自动移除并在 stderr 提示(仅精确匹配会被移除,其他一级标题不受影响)。
- 不传 `--folder` 和 `--workspace` 时,默认创建在「我的文档」根目录。
- `--folder` 仅接受文档文件夹 `nodeId` / `dentryUuid` / alidocs 文件夹 URL;**禁止**传入 drive `dentryId`、`parentId`、`spaceId` 这类纯数字 ID。
- 输入方式选择见 [`./doc-update.md` §内容写入管道](./doc-update.md#内容写入管道createupdate-共用)(与 update 共用)。短文本字面量可 `--content`,多行/表格/特殊字符必须 `--content-file` 或 `--content -`。
@@ -45,7 +45,7 @@
| 项目 | 要求 |
|------|------|
| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题 |
| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题(若重复,CLI 会自动移除与 `--name` 相同的首行 H1 并提示) |
| 位置 | 默认创建到我的文档;指定目录时只接受文档文件夹 `nodeId` 或 alidocs 文件夹 URL |
| 正文 | 多行、表格、代码块、特殊字符或长度 >= 2KB 时必须写入 UTF-8 临时 `.md` 文件 |
| 格式 | 按 §JSONML 起稿判定 决定起稿路径:命中 JSONML 起稿条件时**直接用 JSONML 构造**(跳过 markdown);未命中时用 Markdown 起稿,创建后按 [doc-update-workflow.md](./doc-update-workflow.md) 精修 |
@@ -147,7 +147,7 @@ dws doc read --node <nodeId> --content-format jsonml --output /tmp/<name>-readba
- 只使用用户已提供或对话中已确认的正文素材。
- 如果正文素材不足,先补齐文档目标、受众、章节和缺口;不要在本文中临时扩展跨产品采集流程。
- **先按 [doc-style-guideline.md §2.0 类型判断决策表](./doc-style-guideline.md) 确定文档类型,再用对应类型的骨架样板(§2.1 决策型 / §2.2 执行型 / §2.3 说明型 / §2.4 知识沉淀型)**。不要套通用三段式。
- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。
- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。与 `--name` 相同的首行一级标题会被 CLI 自动移除(stderr 有提示),但不要依赖这个兜底。
- 摘要、bullet、引用块、callout 等元素的使用边界以 style-guideline §3-§7 为准。
- 同类信息保持一致:风险、状态、行动项各用一种元素 + 一种视觉语义(style-guideline §1.2 / §5)。
- 临时文件必须保留真实换行,不能把换行写成字面量 `\n`。
@@ -18,6 +18,25 @@ Flags:
--size string 每页数量 (默认 10)
```
### 错误排查
```
Usage:
dws devdoc error diagnose [flags]
Example:
dws devdoc error diagnose --request-id 15r6h45w0muec --format json
dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json
Flags:
--query string 原始排查问题
--request-id string 开放平台 requestId
--trace-id string requestId 的兼容别名
--error-code string 错误码
--error-message string 错误描述,会合并进原始问题
--api string API 名称,会合并进原始问题作为补充检索词
--context string 额外排查上下文,会合并进原始问题
--page int 分页页码 (默认 1)
--size int 分页大小 (默认 10)
```
## live — 直播
### 查看我的直播列表
@@ -91,7 +110,8 @@ Args:
## 意图判断
- 用户说"开发文档/API 文档/接口文档/调用报错" → `devdoc article search`
- 用户说"开发文档/API 文档/接口文档" → `devdoc article search`
- 用户说"调用报错/requestId/traceId/错误码/错误描述" → `devdoc error diagnose`
- 用户说"直播/我的直播" → `live stream list`
- 用户说"搜索技能/找技能/安装技能/技能市场" → `skill search` / `skill install`(按步骤衔接)
@@ -100,6 +120,7 @@ Args:
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `devdoc article search` | 文档链接 | 直接展示给用户 |
| `devdoc error diagnose` | diagnosticInfo、references、materials | 排查开放平台调用错误 |
| `skill search` | `skillId`、名称、描述 | 用户选型后传给 `skill install <skillId> <target>` |
| `skill install` | 安装成功/失败信息 | 确认目标 Agent 目录已注册 |
| `skill publish` | 发布结果(成功或错误信息) | 确认企业技能库已更新 |
+91 -3
View File
@@ -5,7 +5,7 @@ import "testing"
// ── devdoc article search ──────────────────────────────────
func TestDevdocArticleSearch_should_call_correct_tool(t *testing.T) {
cap := setupTestDeps(t, "devdoc")
cap := setupTestDepsWithPreview(t, "devdoc")
root := buildRoot()
err := execCmd(t, root, []string{"devdoc", "article", "search"}, map[string]string{
"keyword": "MCP", "page": "1", "size": "10",
@@ -13,16 +13,18 @@ func TestDevdocArticleSearch_should_call_correct_tool(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertToolName(t, cap, "search_open_platform_docs")
assertToolName(t, cap, "search_open_platform_docs_rag")
}
func TestDevdocArticleSearch_should_pass_keyword(t *testing.T) {
cap := setupTestDeps(t, "devdoc")
cap := setupTestDepsWithPreview(t, "devdoc")
root := buildRoot()
_ = execCmd(t, root, []string{"devdoc", "article", "search"}, map[string]string{
"keyword": "openConversationId", "page": "1", "size": "10",
})
assertToolArg(t, cap, "keyword", "openConversationId")
assertToolArg(t, cap, "page", float64(1))
assertToolArg(t, cap, "size", float64(10))
}
func TestDevdocArticleSearch_should_not_call_when_dry_run(t *testing.T) {
@@ -33,3 +35,89 @@ func TestDevdocArticleSearch_should_not_call_when_dry_run(t *testing.T) {
})
assertCallCount(t, cap, 0)
}
// ── devdoc error diagnose ──────────────────────────────────
func TestDevdocErrorDiagnose_should_call_correct_tool(t *testing.T) {
cap := setupTestDepsWithPreview(t, "devdoc")
root := buildRoot()
err := execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
"request-id": "req-123",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertToolName(t, cap, "search_open_error_code_rag")
}
func TestDevdocErrorDiagnose_should_pass_request_id(t *testing.T) {
cap := setupTestDepsWithPreview(t, "devdoc")
root := buildRoot()
_ = execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
"request-id": "req-123", "page": "2", "size": "5",
})
assertToolArg(t, cap, "requestId", "req-123")
assertToolArg(t, cap, "page", float64(2))
assertToolArg(t, cap, "size", float64(5))
}
func TestDevdocErrorDiagnose_should_map_trace_id_alias(t *testing.T) {
cap := setupTestDepsWithPreview(t, "devdoc")
root := buildRoot()
_ = execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
"trace-id": "trace-abc", "api": "创建日程",
})
assertToolArg(t, cap, "requestId", "trace-abc")
assertToolArg(t, cap, "query", "创建日程")
assertArgNotPresent(t, cap, "traceId")
assertArgNotPresent(t, cap, "apiName")
}
func TestDevdocErrorTroubleshootAlias_should_pass_error_context(t *testing.T) {
cap := setupTestDepsWithPreview(t, "devdoc")
root := buildRoot()
_ = execCmd(t, root, []string{"devdoc", "error", "troubleshoot"}, map[string]string{
"error-code": "33012", "error-message": "missing scope", "context": "create calendar failed",
})
assertToolArg(t, cap, "errorCode", "33012")
assertToolArg(t, cap, "query", "missing scope create calendar failed")
}
func TestDevdocErrorDiagnose_should_merge_cli_only_context_into_query(t *testing.T) {
cap := setupTestDepsWithPreview(t, "devdoc")
root := buildRoot()
err := execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
"query": "机器人回调失败",
"error-message": "missing scope",
"api": "创建日程",
"context": "应用无权限",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertToolArg(t, cap, "query", "机器人回调失败 missing scope 创建日程 应用无权限")
assertArgNotPresent(t, cap, "apiName")
assertArgNotPresent(t, cap, "errorMessage")
assertArgNotPresent(t, cap, "context")
}
func TestDevdocErrorDiagnose_should_reject_api_without_primary_input(t *testing.T) {
cap := setupTestDepsWithPreview(t, "devdoc")
root := buildRoot()
err := execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
"api": "创建日程",
})
if err == nil {
t.Fatal("expected validation error")
}
assertCallCount(t, cap, 0)
}
func TestDevdocErrorDiagnose_should_not_call_when_dry_run(t *testing.T) {
cap := setupTestDepsWithDryRun(t, "devdoc")
root := buildRoot()
_ = execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
"request-id": "req-123",
})
assertCallCount(t, cap, 0)
}
+20 -2
View File
@@ -37,6 +37,7 @@ type mcpCallCapture struct {
mu sync.Mutex
calls []capturedCall
dryRun bool
preview bool
confirm bool
}
@@ -103,6 +104,13 @@ func setupTestDepsWithDryRun(t *testing.T, product string) *mcpCallCapture {
return cap
}
func setupTestDepsWithPreview(t *testing.T, product string) *mcpCallCapture {
t.Helper()
cap := setupTestDeps(t, product)
cap.preview = true
return cap
}
func setupTestDepsAutoConfirm(t *testing.T, product string) *mcpCallCapture {
t.Helper()
cap := setupTestDeps(t, product)
@@ -130,7 +138,7 @@ func execCmdWithArgs(t *testing.T, root *cobra.Command, path []string, flags map
cliArgs = append(cliArgs, path...)
// Add dry-run if capture says so
if cap != nil && cap.dryRun {
if cap != nil && (cap.dryRun || cap.preview) {
cliArgs = append(cliArgs, "--dry-run")
}
// Add --yes if auto-confirm
@@ -164,12 +172,22 @@ func execCmdWithArgs(t *testing.T, root *cobra.Command, path []string, flags map
DryRun bool `json:"dry_run"`
} `json:"invocation"`
}
var flatInv struct {
Tool string `json:"tool"`
Params map[string]any `json:"params"`
DryRun bool `json:"dry_run"`
}
if cap != nil {
if jsonErr := json.Unmarshal(out.Bytes(), &inv); jsonErr == nil && inv.Invocation.Tool != "" {
if !inv.Invocation.DryRun {
if !inv.Invocation.DryRun || cap.preview {
cap.record(inv.Invocation.Tool, inv.Invocation.Params, "")
}
// For dry-run: don't record (matches old behavior: assertCallCount == 0)
} else if jsonErr := json.Unmarshal(out.Bytes(), &flatInv); jsonErr == nil && flatInv.Tool != "" {
dryRunPreview := flatInv.DryRun || cap.dryRun || cap.preview
if !dryRunPreview || cap.preview {
cap.record(flatInv.Tool, flatInv.Params, "")
}
}
}
return nil
+5 -5
View File
@@ -156,7 +156,7 @@ func TestRecoveryClosedLoopDoesNotRecursivelyCaptureExecuteFailures(t *testing.T
t.Fatalf("list_bases calls = %d, want 1", got)
}
if got := calls.docSearch.Load(); got < 1 {
t.Fatalf("search_open_platform_docs calls = %d, want at least 1", got)
t.Fatalf("search_open_platform_docs_rag calls = %d, want at least 1", got)
}
}
@@ -251,7 +251,7 @@ func newRecoveryRuntimeServer(t *testing.T) (*httptest.Server, *recoveryRuntimeC
writeJSONRPCResult(t, w, req["id"], map[string]any{
"tools": []map[string]any{
{
"name": "search_open_platform_docs",
"name": "search_open_platform_docs_rag",
"title": "Search Docs",
"description": "Search docs",
"inputSchema": map[string]any{"type": "object"},
@@ -261,7 +261,7 @@ func newRecoveryRuntimeServer(t *testing.T) (*httptest.Server, *recoveryRuntimeC
case "tools/call":
params, _ := req["params"].(map[string]any)
name, _ := params["name"].(string)
if name != "search_open_platform_docs" {
if name != "search_open_platform_docs_rag" {
t.Fatalf("unexpected devdoc tool %q", name)
}
calls.docSearch.Add(1)
@@ -313,8 +313,8 @@ func writeRecoveryCatalogFixture(t *testing.T, baseURL string) string {
"endpoint": baseURL + "/server/devdoc",
"tools": []any{
map[string]any{
"rpc_name": "search_open_platform_docs",
"canonical_path": "devdoc.search_open_platform_docs",
"rpc_name": "search_open_platform_docs_rag",
"canonical_path": "devdoc.search_open_platform_docs_rag",
},
},
},
+14 -2
View File
@@ -44,7 +44,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
| `calendar` | `references/products/calendar.md` | 13 | 30 |
| `chat` | `references/products/chat.md` | 13 | 31 |
| `contact` | `references/products/contact.md` | 7 | 14 |
| `devdoc` | `references/products/simple.md` | 1 | 5 |
| `devdoc` | `references/products/simple.md` | 2 | 7 |
| `ding` | `references/products/ding.md` | 2 | 5 |
| `report` | `references/products/report.md` | 6 | 26 |
| `todo` | `references/products/todo.md` | 6 | 30 |
@@ -1007,7 +1007,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
---
### devdoc(5 条)
### devdoc(7 条)
#### `dws devdoc article search`
@@ -1036,6 +1036,18 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
- Expected: `dws devdoc article search --keyword 免登鉴权 --format json`
- Flags: `--keyword` = `免登鉴权`
#### `dws devdoc error diagnose`
**devdoc_devdoc_error_diagnose_001**
- Prompt: 排查开放平台 requestId 15r6h45w0muec 的调用失败
- Expected: `dws devdoc error diagnose --request-id 15r6h45w0muec --format json`
- Flags: `--request-id` = `15r6h45w0muec`
**devdoc_devdoc_error_diagnose_002**
- Prompt: 排查开放平台错误码 33012,错误描述 missing scope
- Expected: `dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json`
- Flags: `--error-code` = `33012`, `--error-message` = `missing scope`
---
### todo(30 条)
+19 -3
View File
@@ -2,8 +2,8 @@
## Summary
- **Total Test Cases**: 202
- **Passed**: 202
- **Total Test Cases**: 204
- **Passed**: 204
- **Failed**: 0
- **Pass Rate**: 100.0%
@@ -16,7 +16,7 @@
| calendar | 30 | 30 | 0 | 100.0% |
| chat | 31 | 31 | 0 | 100.0% |
| contact | 14 | 14 | 0 | 100.0% |
| devdoc | 5 | 5 | 0 | 100.0% |
| devdoc | 7 | 7 | 0 | 100.0% |
| ding | 5 | 5 | 0 | 100.0% |
| report | 26 | 26 | 0 | 100.0% |
| todo | 30 | 30 | 0 | 100.0% |
@@ -1133,6 +1133,22 @@
- Command path: PASS (devdoc article search)
- Flags: PASS (1 flags validated)
**devdoc_devdoc_error_diagnose_001** ✅ PASS
- Prompt: 排查开放平台 requestId 15r6h45w0muec 的调用失败
- Expected: `dws devdoc error diagnose --request-id 15r6h45w0muec --format json`
- Skill Reference: references/products/devdoc.md
- Command path: PASS (devdoc error diagnose)
- Flags: PASS (1 flags validated)
**devdoc_devdoc_error_diagnose_002** ✅ PASS
- Prompt: 排查开放平台错误码 33012,错误描述 missing scope
- Expected: `dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json`
- Skill Reference: references/products/devdoc.md
- Command path: PASS (devdoc error diagnose)
- Flags: PASS (2 flags validated)
### ding
**ding_ding_message_recall_001** ✅ PASS
+20 -2
View File
@@ -22,8 +22,8 @@ import (
// TestHostOwnsPATFlow_OnlySignal is the wire-level guard for the
// "custom authorization card" contract: the CLI switches to host-owned
// PAT mode iff the host injects DINGTALK_DWS_AGENTCODE. DINGTALK_AGENT /
// claw-type is purely a server-side routing tag and must NOT influence
// the decision, in either direction.
// claw-type is purely a server-side routing tag and must NOT influence the
// decision, in either direction.
//
// Regression guard: several earlier drafts conflated the two signals,
// causing third-party Agent hosts that only set DINGTALK_DWS_AGENTCODE
@@ -33,6 +33,7 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
cases := []struct {
name string
agentCode string
reversed string
agentEnv string
want bool
}{
@@ -48,6 +49,13 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
agentEnv: "",
want: true,
},
{
name: "reversed draft agent code only → CLI-owned",
agentCode: "",
reversed: "agt-compat",
agentEnv: "",
want: false,
},
{
name: "agent code + DINGTALK_AGENT=default → host-owned",
agentCode: "agt-cursor",
@@ -84,6 +92,7 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, tc.agentCode)
t.Setenv("DWS_DINGTALK_AGENTCODE", tc.reversed)
// DINGTALK_AGENT is set purely to demonstrate that it does NOT
// influence the host-owned decision. The literal env name is
// used here because the auth package no longer exports a
@@ -97,6 +106,15 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
got, tc.want, tc.agentCode, tc.agentEnv,
)
}
if tc.want {
gotCode, gotSource := authpkg.AgentCodeFromEnv()
wantCode := tc.agentCode
wantSource := authpkg.AgentCodeEnv
if gotCode != wantCode || gotSource != wantSource {
t.Fatalf("AgentCodeFromEnv() = (%q, %q), want (%q, %q)",
gotCode, gotSource, wantCode, wantSource)
}
}
})
}
}