Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fa76f8598 | ||
|
|
b175acb48f | ||
|
|
ed1cbd6f06 | ||
|
|
91f44a1efd | ||
|
|
1a7ba01e36 | ||
|
|
6310dcc39e | ||
|
|
f1a68f2424 | ||
|
|
497e4f87d8 | ||
|
|
a8d009aec8 | ||
|
|
1f413fa322 | ||
|
|
ece91bfa3c | ||
|
|
ff33114b2c | ||
|
|
cdd8414891 | ||
|
|
2a82d07311 | ||
|
|
60ac0b409d | ||
|
|
4bc4b60dca | ||
|
|
31e65dda51 | ||
|
|
387ae5ff59 | ||
|
|
838e5453d8 | ||
|
|
330922cdee | ||
|
|
eaa60f95b5 | ||
|
|
e7a3010b81 | ||
|
|
e7ef2c4677 | ||
|
|
8c2093a41a | ||
|
|
5fbf12fe50 | ||
|
|
dd419ca498 | ||
|
|
f826375556 | ||
|
|
cb95207d5a | ||
|
|
1e95d03606 | ||
|
|
6e3f3cbd24 | ||
|
|
ce5e919c52 |
@@ -0,0 +1,39 @@
|
||||
# 此 workflow 把本仓库代码自动镜像到 Gitee,供国内用户访问。
|
||||
# 需要在仓库 Settings → Secrets and variables → Actions 配置两个 secret:
|
||||
# GITEE_TOKEN —— Gitee 私人令牌(在 Gitee 设置→私人令牌生成),需勾选 projects + admin:repo_hook 权限。
|
||||
# GITEE_PRIVATE_KEY —— SSH 私钥(本地 ssh-keygen 生成的私钥),对应公钥需添加到 Gitee 账号的 SSH 公钥中。
|
||||
# 未配置 secret 时(例如 fork 仓库),mirror step 会被自动跳过,不会报错(不会出现红叉)。
|
||||
name: Mirror code to Gitee
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
schedule:
|
||||
- cron: '0 18 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
# GitHub Actions 不允许在 job-level if 直接引用 secrets,因此先用 env 暴露 secret,
|
||||
# 再在 step 里做守卫判断。hub-mirror 必须有 SSH 私钥(GITEE_PRIVATE_KEY)才能推送,
|
||||
# 故以它为守卫:未配置时整步跳过(代码镜像可改由 Gitee 侧 pull-mirror 承担),不报红叉。
|
||||
env:
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_PRIVATE_KEY: ${{ secrets.GITEE_PRIVATE_KEY }}
|
||||
steps:
|
||||
- name: Mirror to Gitee
|
||||
if: env.GITEE_PRIVATE_KEY != ''
|
||||
uses: Yikun/hub-mirror-action@master
|
||||
with:
|
||||
src: "github/DingTalk-Real-AI"
|
||||
dst: "gitee/DingTalk-Real-AI"
|
||||
dst_key: ${{ secrets.GITEE_PRIVATE_KEY }}
|
||||
dst_token: ${{ secrets.GITEE_TOKEN }}
|
||||
account_type: "org"
|
||||
static_list: "dingtalk-workspace-cli"
|
||||
clone_style: "https"
|
||||
force_update: true
|
||||
@@ -58,6 +58,16 @@ jobs:
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
# 把 release 附件(二进制/校验和/skills 包)镜像到 Gitee release,供 install.sh
|
||||
# 的 DWS_GITEE_REPO 开关消费(仓库代码由 Gitee 仓库镜像功能自动同步,附件不在其内)。
|
||||
# 脚本自带门控:未配置 GITEE_TOKEN / GITEE_REPO 时优雅跳过,不影响海外发布。
|
||||
run: ./scripts/release/sync-to-gitee.sh
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
@@ -6,6 +6,86 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.40] - 2026-06-24
|
||||
|
||||
This release adds China-accessible install mirrors so the CLI installs reliably from mainland China, where GitHub raw + Releases are slow or fail.
|
||||
|
||||
### Added
|
||||
|
||||
- **China mirror via Gitee + npmmirror** (#486; `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh`, `scripts/release/sync-to-gitee.sh`, `.github/workflows/release.yml`, `.github/workflows/mirror-to-gitee.yml`) — an opt-in `DWS_GITEE_REPO` env var makes all three installers resolve the latest version and every release asset (binary, `checksums.txt`, `dws-skills.zip`) from the Gitee OpenAPI v5 instead of GitHub; with it unset, installation defaults to GitHub (fully backward compatible). The release pipeline mirrors release attachments to the matching Gitee release after each tag (gated on `GITEE_TOKEN`/`GITEE_REPO`), and a hub-mirror workflow keeps the repo code in sync (gated on `GITEE_PRIVATE_KEY`). README documents three China install channels: Gitee raw script, Gitee release binaries, and the npm package via `registry.npmmirror.com`.
|
||||
|
||||
## [1.0.39] - 2026-06-18
|
||||
|
||||
This release makes the AI-sent indicator opt-in. 1.0.38 unconditionally tagged every user-identity send/reply with the edition claw identity, so the IM server rendered a "Send from AI" badge under every message — and on the open edition a stale hardcoded value even leaked the Wukong-branded label (「悟空AI发送」) to external users. The badge is now off by default and shown only when the caller explicitly asks for it.
|
||||
|
||||
### Added
|
||||
|
||||
- **`--ai-tag` opt-in flag for `chat message send` / `chat message reply`** (#477; `internal/helpers/chat.go`) — by default no `clawType` tool argument is attached, so delivered messages carry no "Send from AI" badge. Passing `--ai-tag` attaches `edition.ClawType()` so the IM server renders the badge (open edition `openClaw` → 「通过AI发送」; the wukong overlay sets its own value → 「悟空AI发送」). Covers the text/Markdown, rich-media, and `--user`/`--open-dingtalk-id` direct send paths plus `reply`. Bot (`send-by-bot`) and webhook sends are intentionally untouched — they already render as bot messages. The badge is opt-in so dws does not brand every message a user sends.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws chat message reply` no longer leaks the Wukong AI label on the open edition** (#475, fixes #474; `internal/helpers/chat.go`, `pkg/edition/edition.go`) — the reply path hardcoded `clawType: "wukong"`, so open-source quoted replies were tagged 「悟空AI发送」 by the IM server, leaking Wukong branding to external users (reported by an external customer integrating via openclaw). The value now derives from the edition via the new `edition.ClawType()` accessor (open → `DefaultOSSClawType` = `openClaw`), and — together with #477 — is only attached when `--ai-tag` is passed. The earlier fix existed on a branch (PR #450) but was never merged to main; #475 cherry-picked it.
|
||||
|
||||
## [1.0.38] - 2026-06-16
|
||||
|
||||
This release adds client-side agent attribution for usage stats, fixes two commands that silently misbehaved (`dws sheet export` hanging, `dws upgrade --dry-run` actually upgrading), hardens the document write path against server-rejected characters, and makes the long-broken `--no-browser` login flag actually work.
|
||||
|
||||
### Added
|
||||
|
||||
- **Client-side `agent_code` detection + per-channel agent instance id for usage stats** (#467; `internal/auth/agent_code_detect.go`, `internal/auth/identity.go`, `docs/agent-code.md`) — every MCP request now carries `x-dingtalk-dws-agent-code` (which agent host is driving dws — e.g. `claudecode` / `codex` / `qoder` / `cursor` / `hermes` / `openclaw`, falling back to `custom`), `x-dws-agent-instance-id` (a per-machine×channel id, `dwsa_<base62(sha256(machineId|agent_code))>`), the existing machine-level `x-dws-agent-id`, and `X-Cli-Version`. Detection is a confidence ladder, each signature verified on real hosts / official docs (never guessed; anything unrecognized resolves to `custom`): T0 explicit `DINGTALK_DWS_AGENTCODE`, T1 per-agent env signatures, T2 `VSCODE_BRAND` covering the whole VS Code fork family, T3 the macOS `__CFBundleIdentifier` map, T4 `custom`. `identity.json` migrates v1 → v2 transparently and keeps `x-dws-agent-id` machine-level for continuity. **Trust boundary:** `agent_code` and both ids are client self-reported and forgeable — they are for stats / observability only and must not be used for auth, authorization, rate-limiting, billing, or revocation. Server-side gateway work (header passthrough allowlist + logging the fields into the warehouse) is required before the data lands and is tracked separately.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws sheet export` no longer hangs for the full ~5-minute poll timeout** (#462; `internal/compat/pipeline.go`) — the pipeline poll loop compared the API status against `pollUntilValue` with case-sensitive `==`, but the API returns `"success"` while the pipeline config declares `"SUCCESS"`, so the match never fired and the loop spun until timeout. Switched to `strings.EqualFold`, aligning with the case-insensitive `normalizeAsyncStatus` helper already used for `doc export` / `aitable export`.
|
||||
- **`dws upgrade --dry-run` now previews instead of performing a real upgrade** (#416, fixes #364; `internal/app/upgrade.go`) — `newUpgradeCommand` registered no `--dry-run` flag and never read the global persistent one, so `--dry-run` fell through and ran a real, irreversible upgrade (download + binary replace), directly contradicting the flag's documented `预览操作内容,不实际执行` contract. It now resolves the target release and platform asset (so "already latest" / "no build for this platform" is still surfaced), prints the 1–5 steps it *would* perform via the side-effect-free `writeDryRunPlan`, and returns before any backup / download / replace. Covered by `TestWriteDryRunPlan_*` and an updated help test.
|
||||
- **`dws doc create` / `dws doc update` strip server-rejected characters instead of failing** (#465; `internal/helpers/doc.go`, `internal/helpers/doc_jsonml.go`) — the Markdown write path sent raw content straight through, and the dangerous-Unicode strip only ran on the JSONML branch, so content carrying C0 control characters (anything `< 0x20` except `\t` / `\n`), DEL (`0x7F`), or zero-width / line-separator codepoints (`U+200D`, `U+2028`, `U+2029`) — common in LLM-generated or copy-pasted text — was rejected by the server-side `RejectControlChars` validator and the command failed. `stripDocDangerousUnicode` is renamed to `stripDocInputUnsafe`, extended to match the authoritative `apiclient.rejectDangerousChars` set, and applied on both the Markdown and JSONML node write paths. Tab and newline are preserved. Ported from dws-wukong.
|
||||
- **`dws auth login --no-browser` is now honored** (#365; `internal/app/auth_command.go`, `internal/auth/device_flow.go`, `internal/auth/oauth_provider.go`) — the flag was already defined (and hidden) but never wired to the login providers, so the browser always opened regardless. The value is now passed into `DeviceFlowProvider.NoBrowser` / `OAuthProvider.NoBrowser` and gates the `openBrowser` call; the flag is also unhidden so headless / remote sessions can discover it.
|
||||
|
||||
## [1.0.37] - 2026-06-11
|
||||
|
||||
This release realigns the npm channel and hardens PAT batch grants. Background on the npm realignment: 1.0.36 was re-cut on GitHub on 2026-06-11 to fold in the canonical-tree poisoned-cache guard (#454), but the npm registry permanently forbids republishing a version number, so the npm package stayed on the original, unguarded cut. 1.0.37 is therefore the first version where **every** distribution channel — GitHub releases, `dws upgrade`, the install scripts, and npm — ships the same guarded build. If you installed 1.0.36 from npm, upgrade to this version.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PAT batch grants carry the agent identity and require explicit confirmation** (#455; `internal/pat/chmod.go`, `internal/auth/channel.go`, `internal/app/runner.go`) — an explicit `--agentCode` flag or the `DINGTALK_DWS_AGENTCODE` env var is now carried into PAT batch plan/grant arguments instead of being dropped, and a missing agentCode is forwarded as absent so the PAT core can apply the server-side default rather than failing. Batch grants now refuse to execute without an explicit `--yes` (dry-run and single-scope grants keep their existing behavior), closing the gap where a multi-scope grant could fire without a deliberate confirmation. Only the canonical env name `DINGTALK_DWS_AGENTCODE` is recognized; draft/reversed spellings from earlier iterations are ignored. Verified against prepub: dry-run, single grant, flag-priority grant, and batch grant all resolve the target agentCode, with the granted rows confirmed server-side. Tests: `internal/pat/chmod_test.go`, `internal/pat/browser_policy_test.go`, `test/unit/pat_host_owned_signal_test.go`.
|
||||
|
||||
## [1.0.36] - 2026-06-10
|
||||
|
||||
This release closes out the poisoned-discovery-cache lock-out for good, with four layers of defense landing together. The lock-out class (seen again on 2026-06-09 as `chat_permission_grant flag redefined: params`): the dynamic command tree is built from cached discovery data **before** Cobra dispatches any command, so a pflag panic fed by a poisoned cache aborted *every* invocation — including `dws cache refresh` and `dws upgrade`, the very commands that could repair it. Now: (1) any panic during the build is recovered instead of crashing (#447), (2) the four known envelope shapes that made pflag panic are skipped at registration so they never fire (#449), (3) when an unknown panic class does fire, the CLI quarantines the poisoned cache and rebuilds itself from a fresh fetch — and `dws upgrade` clears the discovery caches after every binary swap, so simply getting this version onto a machine is enough to escape, no manual cache surgery (#452), and (4) the same guards now also cover the canonical `dws mcp` tree, which is built even earlier and sat outside all three defenses as originally cut (#454 — this release was re-cut on 2026-06-11 to include it; verified against the preserved real poisoned cache from the 2026-05-25 incident). Also in this release: `dws devdoc` gains RAG-backed Open Platform doc search and a new error-diagnosis command (#434), and `dws doc create` stops producing documents with two identical titles (#448).
|
||||
|
||||
**Escaping a locked-out older binary**: a binary ≤1.0.35 bricked by a poisoned cache cannot run `dws upgrade`. Either bypass the cache for one invocation with `DWS_CACHE_DIR=$(mktemp -d) dws upgrade`, or delete `~/.dws/cache/<partition>/tools/` by hand, or reinstall via the install script. Once 1.0.36 is on the machine this never needs doing again.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws devdoc` — RAG-backed Open Platform doc search and error diagnosis** (#434; `internal/helpers/devdoc.go`, `internal/transport/client.go`) — `dws devdoc article search` now routes to the upstream `search_open_platform_docs_rag` tool, returning structured RAG/reference payloads (the CLI stays a thin invoker; no extra AI analysis layer). New `dws devdoc error diagnose` (alias `troubleshoot`) routes to `search_open_error_code_rag` for diagnosing DingTalk Open Platform API errors, with `--request-id` (hidden `--trace-id` kept for compatibility), `--error-code`, `--error-message`, `--api`, `--context`, `--query`, `--page`, `--size`. Transport-side: query parameters required by DingTalk MCP gateway URLs are preserved on the wire but their values are redacted from debug logs. Default MCP / skill hosts stay on production `https://mcp.dingtalk.com` (prepub remains runtime-configurable). Skill docs (mono + multi `dingtalk-devdoc`) and `docs/command-index.md` updated alongside.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CLI no longer bricks when the dynamic command build panics — degrades to built-in commands** (#447; `internal/app/legacy.go`) — `buildEnvelopeCommandsSafe` wraps the envelope-driven build in a local `recover()`. On panic the CLI logs it, prints a stderr hint, and falls back to the hardcoded helper commands, so `auth` / `cache` / `doctor` / `version` / `upgrade` and the helpers stay alive and `dws cache refresh` can rebuild the poisoned cache. Before this, the only recovery from the pre-1.0.32 lock-out class was manually deleting cache files; the duplicate-flag class itself had been fixed at the builder level, but any *future* panic class in the cache-driven build would have bricked the CLI again. Tests: `TestNewLegacyPublicCommandsPanicFallsBackToHelpers`, `TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath`.
|
||||
- **Envelope-driven flag registration no longer panics on the four known malformed-envelope shapes** (#449; `internal/compat/registry.go`) — while reproducing the lock-out byte-for-byte, four envelope shapes were found still forwarded to pflag calls that panic, each bricking every invocation: a flag named `params` / `json` colliding with the reserved payload flags (the original `flag redefined: params` — earlier dedup fixes covered the alias list and Detail-schema path but not the primary name); two bindings resolving to the same long flag name across bindings; two flags claiming the same shorthand; and a multi-character shorthand. Two small guards applied at every registration site (`ApplyBindings`, `registerPositionalAliasFlags`): `canRegisterFlag` skips duplicate/reserved long names (the value stays reachable via `--params`), and `safeShorthand` drops an invalid or already-taken shorthand while keeping the long flag. The trailing `--json` / `--params` registration is now idempotent. Defense in depth with #447: the escape hatch should never trigger for these known vectors. Test: `TestBuildDynamicCommandsSurvivesMalformedFlagEnvelope` (5 table-driven vectors).
|
||||
- **Poisoned discovery cache now self-heals: quarantine + rebuild on panic, and `dws upgrade` clears discovery caches** (#452; `internal/app/legacy.go`, `internal/app/upgrade.go`, `internal/cache/store.go`) — #447's recovery is upgraded from "degrade and ask the user to run `dws cache refresh`" to a two-stage self-heal: on the first build panic the partition's discovery cache is moved aside to `<partition>.quarantined` (kept on disk for inspection; a previous quarantine is replaced so nothing accumulates — new `Store.QuarantinePartition`) and the build retried once against a fresh fetch. If the retry succeeds the user gets the full dynamic command tree with zero manual steps; only a second panic (remote envelope itself still poisoned, or offline) degrades to helper commands with the `cache refresh` hint. Additionally `dws upgrade` purges discovery-derived caches (`market` / `tools` / `detail` across all partitions — new `Store.PurgeDiscoveryData`) after a successful binary swap, leaving the co-located `downloads/` cache untouched, so an upgraded binary always rebuilds its command tree from fresh data instead of inheriting snapshots written by the old version. Tests: `internal/cache/store_quarantine_test.go`, rewritten `internal/app/legacy_panic_fallback_test.go` (self-heal success, double-panic degradation, no-cache no-op, happy path).
|
||||
- **Canonical `dws mcp` tree no longer escapes the poisoned-cache guards** (#454; `internal/cli/canonical.go`, `internal/app/root.go`) — the canonical tree is assembled from cached catalog data *before* the legacy command build, so a pflag panic there — a tool schema property named after the reserved `--params` flag, exactly what the 2026-05-25 incident cache contained — bypassed #447/#449/#452 entirely and still bricked every invocation, including on this release as originally cut. Two layers, mirroring the existing guards: `applyFlagSpecs` skips reserved (`--json`/`--params`), duplicate, and alias-colliding flag names and sanitizes shorthands (`canRegisterToolFlag` / `safeToolShorthand`; a skipped property stays reachable through the reserved JSON payload flags), and `newMCPCommand` wraps the build in the #452 recover → quarantine → retry-once → degrade-to-stub sequence. Verified against the preserved real poisoned cache: the original cut locks out on `--version` / `cache refresh` / `doctor`; this build self-heals on first run and `cache refresh` clears the poison. Tests: `internal/cli/canonical_flag_guard_test.go` (4 cases), `internal/app/canonical_panic_fallback_test.go` (4 cases mirroring the legacy fallback suite).
|
||||
- **`dws doc create` no longer produces a document with two identical headings** (#448; `internal/helpers/doc.go`) — the platform renders the document name as the page title, and LLM agents habitually repeat `# <title>` as the markdown body's first line despite the skill docs saying not to, so duplicate-heading documents kept appearing. The `doc create` helper (which wins the envelope merge via `preferLegacyLeaf`) now strips a leading ATX H1 whose text exactly equals `--name` (trimmed, case-insensitive) before forwarding to `create_document`, printing a stderr note so agents learn the convention. Deliberately conservative: only an exact match is removed (`# 背景` stays), ATX closing hashes are handled without over-trimming names ending in `#` (e.g. `C#`), H2+/setext headings are never touched, and a body that is nothing but the duplicate H1 omits the `markdown` param instead of sending an empty string. JSONML bodies are out of scope. Tests: `TestStripLeadingDuplicateTitleHeading` (9 cases) plus three end-to-end cobra tests asserting the exact `markdown` param sent.
|
||||
|
||||
## [1.0.35] - 2026-06-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`chat message send` @-mentions not rendered in group / direct chat** (#433, `internal/helpers/chat.go`) — when sending a group message or an openDingTalkId direct message (`send_personal_message`) as the current user, the `content` body was packed with `json.Marshal`, whose default HTML escaping turns the `<` `>` in `<@openDingTalkId>` / `<@all>` into `<` `>`. The DingTalk client renders @-mentions by matching the **literal** `<@...>` token, so after escaping the match fails and the mention shows as plain text — while the API still returns `success`, masking the bug. Fix: add `marshalMessageContent`, which serializes `{title,text}` with `json.Encoder` + `SetEscapeHTML(false)`; both the group and openDingTalkId-direct `send_personal_message` paths now use it, preserving the literal `<@...>`. Added regression test `TestChatMessageSendContentNotHTMLEscaped` asserting the content keeps the literal token and is never HTML-escaped. Verified on a real device: `@someone` and `@all` both render as clickable blue mentions.
|
||||
- **`chat` skill docs & scripts aligned to direct-chat `list-direct`** (#424) — `chat message list` now supports group chats only (`--user` / `--open-dingtalk-id` removed); reading a direct chat moves to the dedicated `list-direct` command, but the skill docs and scripts still taught `chat message list --user`, which now errors with `unknown flag: --user`, also breaking `chat_history_with_user.py` (listed as the "preferred" way to query direct chats). This update: `skills/{mono,multi/dingtalk-chat}/references/products/chat.md` switches `message list` to group-only and documents the new `list-direct` command, syncing the intent routing / key-distinction / context-passing tables / caveats; `skills/mono/references/best_practices/01-messaging.md` changes query-private-chat from `list --user` to `list-direct` (the multi version was already updated); `chat_history_with_user.py` (mono + multi) now calls `list-direct` and fixes response parsing (unwraps `result.messages`, aligns `createTime/content/sender` fields — it previously crashed on `'str' object has no attribute 'get'`). Direct-chat sending still uses `chat message send --user` (since v1.0.34 the direct-send rpc is folded into the `send` command; there is no separate `send-direct`). Docs/scripts only; no change to CLI binary behavior.
|
||||
- **`pat chmod` batch authorization did not pass through `agentCode`** (#414, `internal/pat/chmod.go`) — the batch plan / grant paths (`buildBatchPlanArgs` / `batchArgs`) previously carried `agentCode` only in the single-grant `toolArgs`; batch calls omitted it, so a batch authorization with an explicit `agentCode` was processed under the default agent. Fix: the batch plan / grant args now also carry `agentCode`, matching the single-grant path.
|
||||
- **`pat` JSON output escaped the authorization URL into an unreadable form** (#401, `internal/pat`) — the authorization URL attached to PAT error messages, after default HTML escaping, turned `&` into `&`, breaking the link when copied / recognized on mobile. Fix: the PAT error-enrichment JSON output now uses `SetEscapeHTML(false)` (scoped to PAT JSON only), preserving the readable `&` separators.
|
||||
|
||||
## [1.0.34] - 2026-06-03
|
||||
|
||||
### Changed
|
||||
|
||||
- **Service discovery path now carries a version-coded segment** (`internal/market/registry.go`) — the server-list endpoint moves from `/cli/discovery/apis` to `/cli/discovery/apis/bamboo`. The path is now a single `discoveryAPIPath` constant so future version bumps touch one place. Only the path changes; the MCP base host stays on production `https://mcp.dingtalk.com` and the auth / skill / doctor endpoints are untouched. Discovery via the edition `DiscoveryURL` hook (full-URL `FetchServersFromURL`) is unaffected. Server side must serve the new path.
|
||||
|
||||
### Removed
|
||||
|
||||
- **`dws aiapp` — AI application product taken offline** — removed the `aiapp` product surface (`create` / `query` / `modify`) from the CLI: deleted `internal/helpers/aiapp.go`, dropped it from the generator coverage targets and `knownRegistryProducts`, removed the `aiapp` skill references (mono `references/products/aiapp.md` + `dingtalk-aiapp` multi skill), and unpublished the `aiapp` server from the service-discovery envelope. Product count drops from 19 to 18.
|
||||
|
||||
## [1.0.33] - 2026-06-02
|
||||
|
||||
This release merges the multi-contributor `pre-mcp-discovery` feature branch into `main` as a single squash (#391), bringing a large batch of new product surface — full DingTalk **docs** (`doc`), **knowledge base** (`wiki`), **AI app** (`aiapp`), AI-table **forms** + **import/export**, and reworked **mail** / **todo** / **report** command trees — while keeping service discovery pinned to production `https://mcp.dingtalk.com` (the branch's `pre-mcp.dingtalk.com` endpoint change was deliberately excluded; the four host constants in `skill_command.go` / `auth/endpoints.go` / `cli/loader.go` / `market/registry.go` stay on prod). It also folds in the portable auth bundle (`dws auth export` / `import`, #357) and PAT batch authorization (#389).
|
||||
|
||||
@@ -71,9 +71,9 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
|
||||
| Mode | What gets installed | Best for |
|
||||
|------|----------------------|----------|
|
||||
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | 20 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | 18 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 20 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 18 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
|
||||
How to pick:
|
||||
|
||||
@@ -113,6 +113,28 @@ cp dws ~/.local/bin/ # install to PATH
|
||||
|
||||
</details>
|
||||
|
||||
## China mirror
|
||||
|
||||
For users in mainland China, the following channels avoid GitHub network issues. By default (without setting these environment variables) the installer pulls from GitHub.
|
||||
|
||||
**1. Install script + pre-built binary (Gitee mirror):**
|
||||
|
||||
Repository mirror: `https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli`
|
||||
|
||||
```bash
|
||||
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
> With `DWS_GITEE_REPO` set, the installer resolves the latest version and every release asset (binary, checksums, skills) from the Gitee API instead of GitHub. If it is unset, installation defaults to GitHub.
|
||||
|
||||
**2. npm package (npmmirror mirror):**
|
||||
|
||||
```bash
|
||||
npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
|
||||
```
|
||||
|
||||
> npmmirror automatically syncs public packages from the public npm registry, so this works directly in China.
|
||||
|
||||
## Upgrade
|
||||
|
||||
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
|
||||
@@ -277,7 +299,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
The repo ships a complete Agent Skill system under `skills/`, now organized into two layouts:
|
||||
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 20 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 18 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
|
||||
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
|
||||
|
||||
@@ -492,13 +514,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) |
|
||||
| AI App | `aiapp` | 4 | — | AI application lifecycle: `create` (with prompt / attachments / skills) / `query` (by task ID) / `modify` (by thread ID) |
|
||||
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
|
||||
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
|
||||
|
||||
> **334 commands across 19 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
|
||||
> **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.
|
||||
|
||||
|
||||
+27
-6
@@ -71,9 +71,9 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 20 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 18 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。20 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。18 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
|
||||
怎么选:
|
||||
|
||||
@@ -113,6 +113,28 @@ cp dws ~/.local/bin/ # 安装到 PATH
|
||||
|
||||
</details>
|
||||
|
||||
## 国内加速安装
|
||||
|
||||
国内用户可使用以下通道,避免 GitHub 网络问题。默认(不设置这些环境变量)走 GitHub。
|
||||
|
||||
**1. 安装脚本 + 预编译二进制(Gitee 镜像):**
|
||||
|
||||
仓库镜像地址:`https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli`
|
||||
|
||||
```bash
|
||||
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
> 设置 `DWS_GITEE_REPO` 后,安装脚本会改从 Gitee API 解析最新版本和各个 release 产物(二进制、校验和、skills 包),而不是走 GitHub。不设置时默认从 GitHub 安装。
|
||||
|
||||
**2. npm 包(npmmirror 镜像):**
|
||||
|
||||
```bash
|
||||
npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
|
||||
```
|
||||
|
||||
> npmmirror 会自动同步公网 npm 的公开包,国内可直接使用。
|
||||
|
||||
## 升级
|
||||
|
||||
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
|
||||
@@ -274,7 +296,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),目前重组为两套布局:
|
||||
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 20 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 18 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
|
||||
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
|
||||
|
||||
@@ -488,13 +510,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` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
|
||||
| AI 应用 | `aiapp` | 4 | — | AI 应用生命周期:`create`(含 prompt / attachments / skills)/ `query`(按任务 ID)/ `modify`(按 thread ID) |
|
||||
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
|
||||
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
|
||||
|
||||
> **19 个产品,334 条命令。** 完整命令清单(带描述与使用场景):[`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` 产品。
|
||||
|
||||
|
||||
@@ -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,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. |
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -359,7 +359,7 @@ func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadata(t *testing.T
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/cli/discovery/apis":
|
||||
case r.URL.Path == "/cli/discovery/apis/bamboo":
|
||||
payload := map[string]any{
|
||||
"metadata": map[string]any{"count": 2, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
@@ -433,7 +433,7 @@ func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadataWhenRegistryT
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/cli/discovery/apis":
|
||||
case r.URL.Path == "/cli/discovery/apis/bamboo":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"metadata": map[string]any{"count": 2, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// captureStderr redirects os.Stderr for the duration of fn and returns what
|
||||
// was written to it.
|
||||
func captureStderr(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
pipeR, pipeW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe() error = %v", err)
|
||||
}
|
||||
origStderr := os.Stderr
|
||||
os.Stderr = pipeW
|
||||
defer func() { os.Stderr = origStderr }()
|
||||
|
||||
fn()
|
||||
|
||||
_ = pipeW.Close()
|
||||
os.Stderr = origStderr
|
||||
captured, _ := io.ReadAll(pipeR)
|
||||
return string(captured)
|
||||
}
|
||||
|
||||
// TestNewLegacyPublicCommandsPanicFallsBackToHelpers verifies the escape
|
||||
// hatch for a poisoned discovery cache: when the dynamic command build
|
||||
// panics (e.g. duplicate pflag registration, the pre-1.0.32 lock-out
|
||||
// "flag redefined: params"), newLegacyPublicCommands must NOT propagate
|
||||
// the panic. With no on-disk cache to quarantine there is nothing to
|
||||
// self-heal from, so it degrades to the hardcoded helper commands and
|
||||
// prints a stderr hint pointing at `dws cache refresh`.
|
||||
func TestNewLegacyPublicCommandsPanicFallsBackToHelpers(t *testing.T) {
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
calls := 0
|
||||
orig := loadDynamicCommandsFn
|
||||
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
|
||||
calls++
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
t.Cleanup(func() { loadDynamicCommandsFn = orig })
|
||||
|
||||
var cmds []*cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmds = newLegacyPublicCommands(context.Background(), nil)
|
||||
})
|
||||
|
||||
if len(cmds) == 0 {
|
||||
t.Fatalf("newLegacyPublicCommands() = 0 commands after build panic, want helper fallback set")
|
||||
}
|
||||
if !strings.Contains(captured, "dws cache refresh") {
|
||||
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("dynamic build attempts = %d, want 1 (no cache on disk, nothing to quarantine and retry)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewLegacyPublicCommandsSelfHealsPoisonedCache verifies the self-heal
|
||||
// path: when the build panics AND a discovery cache exists on disk, the
|
||||
// partition is quarantined (moved aside, kept for inspection) and the build
|
||||
// retried once. The retry succeeding means the user gets the full dynamic
|
||||
// command tree with zero manual cache surgery.
|
||||
func TestNewLegacyPublicCommandsSelfHealsPoisonedCache(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, tmp)
|
||||
|
||||
store := cache.NewStore(tmp)
|
||||
partition := editionPartition()
|
||||
if err := store.SaveTools(partition, "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
orig := loadDynamicCommandsFn
|
||||
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
return []*cobra.Command{{Use: "dynamic-probe"}}
|
||||
}
|
||||
t.Cleanup(func() { loadDynamicCommandsFn = orig })
|
||||
|
||||
var cmds []*cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmds = newLegacyPublicCommands(context.Background(), nil)
|
||||
})
|
||||
|
||||
if calls != 2 {
|
||||
t.Fatalf("dynamic build attempts = %d, want 2 (initial + retry after quarantine)", calls)
|
||||
}
|
||||
found := false
|
||||
for _, c := range cmds {
|
||||
if c.Name() == "dynamic-probe" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("newLegacyPublicCommands() did not return the rebuilt dynamic command tree; got %d commands without 'dynamic-probe'", len(cmds))
|
||||
}
|
||||
|
||||
quarantines, _ := filepath.Glob(filepath.Join(tmp, "*.quarantined"))
|
||||
if len(quarantines) != 1 {
|
||||
t.Fatalf("quarantine dirs = %v, want exactly 1", quarantines)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(quarantines[0], "tools", "poisoned-server.json")); err != nil {
|
||||
t.Errorf("poisoned snapshot not preserved in quarantine: %v", err)
|
||||
}
|
||||
if !strings.Contains(captured, "rebuilding from a fresh fetch") {
|
||||
t.Errorf("stderr = %q, want a note about rebuilding from a fresh fetch", captured)
|
||||
}
|
||||
if strings.Contains(captured, "dws cache refresh") {
|
||||
t.Errorf("stderr = %q, must not tell the user to run 'dws cache refresh' when the rebuild succeeded", captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewLegacyPublicCommandsSecondPanicDegradesToHelpers verifies the final
|
||||
// safety net: if the rebuild after quarantine panics again (remote envelope
|
||||
// still poisoned, or offline), the CLI degrades to helper commands and keeps
|
||||
// the `dws cache refresh` hint.
|
||||
func TestNewLegacyPublicCommandsSecondPanicDegradesToHelpers(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, tmp)
|
||||
|
||||
store := cache.NewStore(tmp)
|
||||
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
orig := loadDynamicCommandsFn
|
||||
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
|
||||
calls++
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
t.Cleanup(func() { loadDynamicCommandsFn = orig })
|
||||
|
||||
var cmds []*cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmds = newLegacyPublicCommands(context.Background(), nil)
|
||||
})
|
||||
|
||||
if calls != 2 {
|
||||
t.Fatalf("dynamic build attempts = %d, want 2 (initial + retry after quarantine)", calls)
|
||||
}
|
||||
if len(cmds) == 0 {
|
||||
t.Fatalf("newLegacyPublicCommands() = 0 commands after repeated build panics, want helper fallback set")
|
||||
}
|
||||
if !strings.Contains(captured, "dws cache refresh") {
|
||||
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath ensures the guard is
|
||||
// transparent on the happy path: commands returned by the dynamic build
|
||||
// still reach the caller unchanged.
|
||||
func TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath(t *testing.T) {
|
||||
orig := loadDynamicCommandsFn
|
||||
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
|
||||
return []*cobra.Command{{Use: "dynamic-probe"}}
|
||||
}
|
||||
t.Cleanup(func() { loadDynamicCommandsFn = orig })
|
||||
|
||||
cmds := newLegacyPublicCommands(context.Background(), nil)
|
||||
|
||||
found := false
|
||||
for _, c := range cmds {
|
||||
if c.Name() == "dynamic-probe" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("newLegacyPublicCommands() lost the dynamic command; got %d commands without 'dynamic-probe'", len(cmds))
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
@@ -229,7 +230,7 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
|
||||
}
|
||||
data["openBrowser"] = openBrowser
|
||||
|
||||
encoded, err := json.Marshal(payload)
|
||||
encoded, err := marshalSingleLineJSONNoHTMLEscape(payload)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
@@ -595,7 +596,7 @@ func enrichPATErrorForHostControl(raw string) string {
|
||||
apperrors.ApplyHostMutations(payload)
|
||||
|
||||
// stderr JSON MUST be single-line.
|
||||
encoded, err := json.Marshal(payload)
|
||||
encoded, err := marshalSingleLineJSONNoHTMLEscape(payload)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
@@ -636,6 +637,20 @@ func buildPATScopeJSON(scopeErr *PatScopeError, includeHostControl bool) string
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func marshalSingleLineJSONNoHTMLEscape(v any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := buf.Bytes()
|
||||
if len(out) > 0 && out[len(out)-1] == '\n' {
|
||||
out = out[:len(out)-1]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pollPatDeviceFlow polls the PAT device flow status endpoint until a terminal
|
||||
// state (APPROVED/REJECTED/EXPIRED) is reached or the context is cancelled.
|
||||
// Returns the final status string and the authCode (non-empty only on APPROVED).
|
||||
|
||||
@@ -590,6 +590,29 @@ func makePATErrorJSONWithURI(flowID, clientID, uri string) string {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestEnrichPATErrorWithOpenBrowserKeepsAuthorizationURLAmpersandReadable(t *testing.T) {
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-copy%26userCode%3DQZYH-D64W#/personalAuthorization?flowId=flow-copy&userCode=QZYH-D64W"
|
||||
raw := makePATErrorJSONWithURI("flow-copy", "test-client-id", rawURI)
|
||||
|
||||
out := enrichPATErrorWithOpenBrowser(raw, true)
|
||||
|
||||
if strings.Contains(out, `\u0026`) {
|
||||
t.Fatalf("enriched PAT JSON should keep URL ampersands readable for mobile copy/linkify, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "&userCode=QZYH-D64W") {
|
||||
t.Fatalf("enriched PAT JSON missing readable authorization URL separator, got: %s", out)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(enriched PAT payload) error = %v\nraw=%s", err, out)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, _ := data["authorizationUrl"].(string); got != rawURI {
|
||||
t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
|
||||
@@ -1082,6 +1105,21 @@ func TestEnrichPATErrorForHostControl_SingleLineOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPATErrorForHostControlKeepsAuthorizationURLAmpersandReadable(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
|
||||
t.Setenv("DINGTALK_AGENT", "sales-copilot")
|
||||
|
||||
raw := `{"success":false,"code":"PAT_HIGH_RISK_NO_PERMISSION","data":{"flowId":"flow-host","desc":"授权","uri":"https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-host%26userCode%3DQZYH-D64W#/personalAuthorization?flowId=flow-host&userCode=QZYH-D64W"}}`
|
||||
out := enrichPATErrorForHostControl(raw)
|
||||
|
||||
if strings.Contains(out, `\u0026`) {
|
||||
t.Fatalf("host PAT JSON should keep URL ampersands readable for mobile copy/linkify, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "&userCode=QZYH-D64W") {
|
||||
t.Fatalf("host PAT JSON missing readable authorization URL separator, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPATScopeHostJSON_SingleLineOutput mirrors the above regression
|
||||
// for the scope-error branch (PAT_SCOPE_AUTH_REQUIRED emission).
|
||||
func TestBuildPATScopeHostJSON_SingleLineOutput(t *testing.T) {
|
||||
|
||||
@@ -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
@@ -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,7 +24,7 @@ func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T)
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/cli/discovery/apis":
|
||||
case "/cli/discovery/apis/bamboo":
|
||||
_ = json.NewEncoder(w).Encode(market.ListResponse{
|
||||
Metadata: market.ListMetadata{Count: 2},
|
||||
Servers: []market.ServerEnvelope{
|
||||
@@ -146,7 +146,7 @@ func TestCacheRefreshHonorsEditionDiscoveryURL(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
case "/cli/discovery/apis":
|
||||
case "/cli/discovery/apis/bamboo":
|
||||
marketHits.Add(1)
|
||||
http.Error(w, "market endpoint must not be called when edition DiscoveryURL is set", http.StatusNotFound)
|
||||
default:
|
||||
|
||||
+24
-2
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -95,10 +95,11 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
return fmt.Errorf("--skill / --exclude 仅在 --mode multi 下有效(mono 只有一个 skill,无需挑选)")
|
||||
}
|
||||
|
||||
skillSrc, err := resolveSkillSetupSource(source, mode)
|
||||
skillSrc, srcCleanup, err := resolveSkillSetupSourceOrEmbedded(source, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcCleanup()
|
||||
|
||||
dests, err := resolveSkillSetupTargets(target, mode)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
dwsroot "github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
)
|
||||
|
||||
// resolveSkillSetupSourceOrEmbedded resolves the skill source for `skill
|
||||
// setup`. An explicit --source or DWS_SKILL_SOURCE is honored as a developer
|
||||
// override (validated as an on-disk dir). Otherwise it falls back to the skill
|
||||
// bundle embedded in THIS binary, so a plain `dws skill setup` always installs
|
||||
// the version shipped with the running binary — upgrading the binary therefore
|
||||
// refreshes the installed skill, instead of silently reusing a stale copy from
|
||||
// the current working directory.
|
||||
//
|
||||
// The returned cleanup func removes any temp dir created for the embedded
|
||||
// bundle; it is a no-op when an on-disk source is used. Always call it.
|
||||
func resolveSkillSetupSourceOrEmbedded(explicit, mode string) (string, func(), error) {
|
||||
noop := func() {}
|
||||
explicit = strings.TrimSpace(explicit)
|
||||
env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE"))
|
||||
if explicit != "" || env != "" {
|
||||
dir, err := resolveSkillSetupSource(explicit, mode)
|
||||
return dir, noop, err
|
||||
}
|
||||
return materializeEmbeddedSkillSource(mode)
|
||||
}
|
||||
|
||||
// materializeEmbeddedSkillSource extracts the embedded skills/<mode> subtree
|
||||
// into a fresh temp dir and returns its path plus a cleanup func. Reusing a
|
||||
// real directory lets the existing dir-based install/copy logic stay unchanged.
|
||||
func materializeEmbeddedSkillSource(mode string) (string, func(), error) {
|
||||
noop := func() {}
|
||||
sub := "skills/" + mode // embed.FS always uses forward slashes
|
||||
if _, err := fs.Stat(dwsroot.EmbeddedSkills, sub); err != nil {
|
||||
return "", noop, fmt.Errorf("内嵌 skill 不含 %q(二进制可能未随 skills/ 重新构建): %w", sub, err)
|
||||
}
|
||||
|
||||
tmp, err := os.MkdirTemp("", "dws-skill-"+mode+"-")
|
||||
if err != nil {
|
||||
return "", noop, fmt.Errorf("创建临时 skill 目录失败: %w", err)
|
||||
}
|
||||
cleanup := func() { _ = os.RemoveAll(tmp) }
|
||||
|
||||
walkErr := fs.WalkDir(dwsroot.EmbeddedSkills, sub, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel := strings.TrimPrefix(strings.TrimPrefix(p, sub), "/")
|
||||
dst := filepath.Join(tmp, filepath.FromSlash(rel))
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(dst, 0o755)
|
||||
}
|
||||
data, readErr := dwsroot.EmbeddedSkills.ReadFile(p)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if mkErr := os.MkdirAll(filepath.Dir(dst), 0o755); mkErr != nil {
|
||||
return mkErr
|
||||
}
|
||||
return os.WriteFile(dst, data, 0o644)
|
||||
})
|
||||
if walkErr != nil {
|
||||
cleanup()
|
||||
return "", noop, fmt.Errorf("展开内嵌 skill 到临时目录失败: %w", walkErr)
|
||||
}
|
||||
return tmp, cleanup, nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMaterializeEmbeddedSkillSourceMono verifies that the mono skill bundle
|
||||
// baked into the binary can be extracted to a temp dir and is a valid skill
|
||||
// source root (so `dws skill setup` works with zero local checkout). The
|
||||
// nested-reference and _common checks guard against the embed dropping nested
|
||||
// docs or the `all:` prefix being lost (which would silently skip
|
||||
// dot/underscore dirs).
|
||||
func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
|
||||
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMono)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeEmbeddedSkillSource: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if !isSkillSourceRoot(dir, skillSetupModeMono) {
|
||||
t.Fatalf("extracted dir %s is not a valid mono skill source root", dir)
|
||||
}
|
||||
for _, rel := range []string{
|
||||
"SKILL.md",
|
||||
filepath.Join("references", "global-reference.md"),
|
||||
filepath.Join("references", "best_practices", "_common"),
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
|
||||
t.Errorf("expected embedded skill to contain %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanup must actually remove the temp dir.
|
||||
cleanup()
|
||||
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||
t.Errorf("cleanup did not remove temp dir %s (err=%v)", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveSkillSetupSourceOrEmbeddedFallsBackToEmbedded verifies that with
|
||||
// no --source and no DWS_SKILL_SOURCE, resolution uses the embedded bundle
|
||||
// rather than probing the current working directory (the stale-skill footgun).
|
||||
func TestResolveSkillSetupSourceOrEmbeddedFallsBackToEmbedded(t *testing.T) {
|
||||
t.Setenv("DWS_SKILL_SOURCE", "")
|
||||
dir, cleanup, err := resolveSkillSetupSourceOrEmbedded("", skillSetupModeMono)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSkillSetupSourceOrEmbedded: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
if !isSkillSourceRoot(dir, skillSetupModeMono) {
|
||||
t.Fatalf("embedded fallback returned non-source-root dir %s", dir)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(), "")
|
||||
|
||||
Vendored
+68
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,13 @@ import (
|
||||
)
|
||||
|
||||
// newTestMCPServer returns an httptest.Server that handles both market registry
|
||||
// and MCP JSON-RPC endpoints. marketOK controls whether /cli/discovery/apis
|
||||
// and MCP JSON-RPC endpoints. marketOK controls whether /cli/discovery/apis/bamboo
|
||||
// succeeds, and mcpOK controls whether initialize+tools/list succeed.
|
||||
func newTestMCPServer(t *testing.T, marketOK, mcpOK bool) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/cli/discovery/apis", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !marketOK {
|
||||
http.Error(w, "market unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
+16
-1
@@ -14,6 +14,7 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
@@ -462,13 +463,27 @@ func cleanPATJSON(body map[string]any, code string) string {
|
||||
// stderr JSON MUST be a single-line, directly json.Unmarshal-able
|
||||
// payload — pretty-printing would break naïve host parsers that read
|
||||
// stderr line-by-line and fail on leading whitespace.
|
||||
b, err := json.Marshal(out)
|
||||
b, err := marshalSingleLineJSONNoHTMLEscape(out)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"success":false,"code":"%s"}`, code)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func marshalSingleLineJSONNoHTMLEscape(v any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := buf.Bytes()
|
||||
if len(out) > 0 && out[len(out)-1] == '\n' {
|
||||
out = out[:len(out)-1]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- Runner adapter functions ------------------------------------------------
|
||||
// These match the function signatures referenced by runner.go's PAT check
|
||||
// framework (ClassifyPatAuthCheck / AsPatAuthCheckError).
|
||||
|
||||
@@ -730,6 +730,13 @@ func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) {
|
||||
|
||||
result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION")
|
||||
|
||||
if strings.Contains(result, `\u0026`) {
|
||||
t.Fatalf("cleanPATJSON should keep URL ampersands readable for mobile copy/linkify, got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "&userCode=Q8RY-X6E9") {
|
||||
t.Fatalf("cleanPATJSON output missing readable fragment separator, got: %s", result)
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
|
||||
t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -259,7 +259,7 @@ func newDocsMCPGateway(expectations []docsServerExpectation) *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
server := httptest.NewServer(mux)
|
||||
|
||||
mux.HandleFunc("/cli/discovery/apis", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
|
||||
@@ -82,7 +82,6 @@ var writeOperationTokens = map[string]struct{}{
|
||||
}
|
||||
|
||||
var legacy17CoverageTargets = []string{
|
||||
"aiapp",
|
||||
"aitable",
|
||||
"attendance",
|
||||
"calendar",
|
||||
@@ -102,7 +101,6 @@ var legacy17CoverageTargets = []string{
|
||||
}
|
||||
|
||||
var extended22CoverageTargets = []string{
|
||||
"aiapp",
|
||||
"aitable",
|
||||
"attendance",
|
||||
"calendar",
|
||||
|
||||
@@ -77,7 +77,6 @@ type RecipeEntry struct {
|
||||
}
|
||||
|
||||
var knownRegistryProducts = map[string]struct{}{
|
||||
"aiapp": {},
|
||||
"aidesign": {},
|
||||
"aitable": {},
|
||||
"attendance": {},
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"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/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterPublic(func() Handler {
|
||||
return aiappHandler{}
|
||||
})
|
||||
}
|
||||
|
||||
type aiappHandler struct{}
|
||||
|
||||
func (aiappHandler) Name() string {
|
||||
return "aiapp"
|
||||
}
|
||||
|
||||
func (aiappHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "aiapp",
|
||||
Short: "AI 应用创建 / 查询 / 修改",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
root.AddCommand(
|
||||
newAIAppCreateCommand(runner),
|
||||
newAIAppQueryCommand(runner),
|
||||
newAIAppModifyCommand(runner),
|
||||
)
|
||||
return root
|
||||
}
|
||||
|
||||
func newAIAppCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建 AI 应用",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
prompt, err := aiappRequiredFlag(cmd, "prompt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{"prompt": prompt}
|
||||
if err := addAIAppOptionalInputs(cmd, params); err != nil {
|
||||
return err
|
||||
}
|
||||
return runAIAppTool(cmd, runner, "create_ai_app", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("prompt", "", "创建 AI 应用的 prompt (必填)")
|
||||
cmd.Flags().String("attachments", "", "附件对象数组 JSON")
|
||||
cmd.Flags().String("skills", "", "技能 ID 列表,逗号分隔")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAIAppQueryCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "query",
|
||||
Short: "查询 AI 应用任务",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
taskID, err := aiappRequiredFlag(cmd, "task-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runAIAppTool(cmd, runner, "query_ai_app", map[string]any{"taskId": taskID})
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("task-id", "", "AI 应用任务 ID (必填)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAIAppModifyCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "modify",
|
||||
Short: "修改 AI 应用",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
prompt, err := aiappRequiredFlag(cmd, "prompt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
threadID, err := aiappRequiredFlag(cmd, "thread-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"prompt": prompt,
|
||||
"threadId": threadID,
|
||||
}
|
||||
if skills := aiappStringFlag(cmd, "skills"); skills != "" {
|
||||
params["officialSkillUids"] = aiappCSV(skills)
|
||||
}
|
||||
return runAIAppTool(cmd, runner, "modify_ai_app", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("prompt", "", "新的 prompt (必填)")
|
||||
cmd.Flags().String("thread-id", "", "threadId (必填)")
|
||||
cmd.Flags().String("skills", "", "技能 ID 列表,逗号分隔")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func addAIAppOptionalInputs(cmd *cobra.Command, params map[string]any) error {
|
||||
if attachments := aiappStringFlag(cmd, "attachments"); attachments != "" {
|
||||
var values []any
|
||||
if err := json.Unmarshal([]byte(attachments), &values); err != nil {
|
||||
return apperrors.NewValidation("--attachments must be a JSON array: " + err.Error())
|
||||
}
|
||||
if len(values) > 0 {
|
||||
params["attachments"] = values
|
||||
}
|
||||
}
|
||||
if skills := aiappStringFlag(cmd, "skills"); skills != "" {
|
||||
params["officialSkillUids"] = aiappCSV(skills)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAIAppTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error {
|
||||
invocation := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
"aiapp",
|
||||
tool,
|
||||
params,
|
||||
)
|
||||
if commandDryRun(cmd) {
|
||||
return writeCommandPayload(cmd, invocation)
|
||||
}
|
||||
result, err := runner.Run(cmd.Context(), invocation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
}
|
||||
|
||||
func aiappRequiredFlag(cmd *cobra.Command, name string) (string, error) {
|
||||
if value := aiappStringFlag(cmd, name); value != "" {
|
||||
return value, nil
|
||||
}
|
||||
return "", apperrors.NewValidation("--" + name + " is required")
|
||||
}
|
||||
|
||||
func aiappStringFlag(cmd *cobra.Command, name string) string {
|
||||
if value, err := cmd.Flags().GetString(name); err == nil && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func aiappCSV(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
values := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if value := strings.TrimSpace(part); value != "" {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
)
|
||||
|
||||
type aiappCommandRunner struct {
|
||||
last executor.Invocation
|
||||
}
|
||||
|
||||
func (r *aiappCommandRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
r.last = invocation
|
||||
return executor.Result{Invocation: invocation}, nil
|
||||
}
|
||||
|
||||
func TestAIAppModifyMatchesWukongPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aiappCommandRunner{}
|
||||
cmd := aiappHandler{}.Command(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{
|
||||
"modify",
|
||||
"--prompt", "根据新图片优化首页视觉风格",
|
||||
"--thread-id", "THREAD_001",
|
||||
"--skills", "s1,s2",
|
||||
})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if runner.last.Tool != "modify_ai_app" {
|
||||
t.Fatalf("tool = %q, want modify_ai_app", runner.last.Tool)
|
||||
}
|
||||
if got := runner.last.Params["threadId"]; got != "THREAD_001" {
|
||||
t.Fatalf("threadId = %#v, want THREAD_001", got)
|
||||
}
|
||||
if _, ok := runner.last.Params["attachments"]; ok {
|
||||
t.Fatalf("attachments should not be sent by modify_ai_app: %#v", runner.last.Params["attachments"])
|
||||
}
|
||||
skills, ok := runner.last.Params["officialSkillUids"].([]string)
|
||||
if !ok || len(skills) != 2 || skills[0] != "s1" || skills[1] != "s2" {
|
||||
t.Fatalf("officialSkillUids = %#v, want [s1 s2]", runner.last.Params["officialSkillUids"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIAppCreateRejectsInvalidAttachmentsJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aiappCommandRunner{}
|
||||
cmd := aiappHandler{}.Command(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{"create", "--prompt", "创建应用", "--attachments", `{"bad":true}`})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("Execute() error = nil, want JSON array validation failure")
|
||||
}
|
||||
if runner.last.Tool != "" {
|
||||
t.Fatalf("tool = %q, want no invocation", runner.last.Tool)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
@@ -22,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"
|
||||
)
|
||||
|
||||
@@ -332,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)。
|
||||
@@ -441,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
|
||||
}
|
||||
@@ -475,12 +488,12 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
if atAll && !strings.Contains(text, "<@all>") {
|
||||
text = "<@all> " + text
|
||||
}
|
||||
b, _ := json.Marshal(map[string]string{"title": title, "text": text})
|
||||
params := map[string]any{
|
||||
"openConversationId": group,
|
||||
"msgType": "markdown",
|
||||
"content": string(b),
|
||||
"content": marshalMessageContent(title, text),
|
||||
}
|
||||
attachAITag(cmd, params)
|
||||
if atAll {
|
||||
params["atAll"] = true
|
||||
}
|
||||
@@ -493,14 +506,15 @@ 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:
|
||||
b, _ := json.Marshal(map[string]string{"title": title, "text": text})
|
||||
params := map[string]any{
|
||||
"receiverOpenDingTalkId": openID,
|
||||
"msgType": "markdown",
|
||||
"content": string(b),
|
||||
"content": marshalMessageContent(title, text),
|
||||
}
|
||||
attachAITag(cmd, params)
|
||||
if strings.TrimSpace(uuid) != "" {
|
||||
params["uuid"] = uuid
|
||||
}
|
||||
@@ -976,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
|
||||
}
|
||||
@@ -1001,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
|
||||
}
|
||||
|
||||
@@ -1011,3 +1028,19 @@ func jsonMarshal(v any) (string, error) {
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// marshalMessageContent builds the send_personal_message content payload
|
||||
// ({"title","text"}) WITHOUT HTML-escaping < > &. DingTalk's client renders
|
||||
// @-mentions by matching literal <@openDingTalkId> / <@all> tokens in the
|
||||
// message text; the default json.Marshal escaping turns them into
|
||||
// <@...>, which the client shows as plain text instead of a rendered
|
||||
// mention. encoding/json offers no escape toggle on Marshal, so use an Encoder.
|
||||
func marshalMessageContent(title, text string) string {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
// Encoder errors are impossible for a map[string]string; ignore safely.
|
||||
_ = enc.Encode(map[string]string{"title": title, "text": text})
|
||||
// Encoder.Encode appends a trailing newline; strip it.
|
||||
return strings.TrimRight(buf.String(), "\n")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -222,6 +224,55 @@ func TestChatMessageSendForwardsAtMentions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMessageSendContentNotHTMLEscaped guards the @-mention rendering fix:
|
||||
// the send_personal_message content must keep literal <@openDingTalkId> / <@all>
|
||||
// tokens. If json.Marshal's default HTML escaping is reintroduced, the tokens
|
||||
// become <@...> and the DingTalk client renders them as plain text
|
||||
// instead of a real @-mention.
|
||||
func TestChatMessageSendContentNotHTMLEscaped(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string // literal token that must survive in content
|
||||
}{
|
||||
{
|
||||
name: "group-at-all",
|
||||
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "<@all> hi", "--at-all"},
|
||||
want: "<@all>",
|
||||
},
|
||||
{
|
||||
name: "group-at-open-dingtalk-id",
|
||||
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "<@op-1> hi", "--at-open-dingtalk-ids", "op-1"},
|
||||
want: "<@op-1>",
|
||||
},
|
||||
{
|
||||
name: "direct-open-dingtalk-id",
|
||||
args: []string{"--open-dingtalk-id", "OP123", "--title", "t", "--text", "<@OP123> hi"},
|
||||
want: "<@OP123>",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
cmd := newChatMessageSendCommand(runner)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs(tc.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
content, _ := runner.last.Params["content"].(string)
|
||||
if !strings.Contains(content, tc.want) {
|
||||
t.Fatalf("content %q missing literal %q (HTML-escaped?)", content, tc.want)
|
||||
}
|
||||
if strings.Contains(content, "\\u003c") || strings.Contains(content, "\\u003e") {
|
||||
t.Fatalf("content %q is HTML-escaped; @-mention will not render", content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMessageSendRejectsAtMentionsOutsideGroup ensures we do not silently
|
||||
// drop user intent when --at-* is combined with --user / --open-dingtalk-id
|
||||
// (single-chat tools have no @-mention semantics, so the flag would never
|
||||
@@ -259,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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,14 @@ 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
|
||||
// MCP server list. Kept as a single constant so the version-coded
|
||||
// segment (".../bamboo") lives in one place and future version bumps
|
||||
// only touch here. See registry_test.go for the matching fixture path.
|
||||
discoveryAPIPath = "/cli/discovery/apis/bamboo"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
@@ -544,7 +550,7 @@ func (c *Client) FetchServersFromURL(ctx context.Context, fullURL string) (ListR
|
||||
}
|
||||
|
||||
func (c *Client) fetchServersPage(ctx context.Context, limit int, cursor string) (ListResponse, error) {
|
||||
reqURL, err := url.Parse(c.BaseURL + "/cli/discovery/apis")
|
||||
reqURL, err := url.Parse(c.BaseURL + discoveryAPIPath)
|
||||
if err != nil {
|
||||
return ListResponse{}, apperrors.NewDiscovery("failed to build market servers URL")
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ func TestFetchServers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/cli/discovery/apis" {
|
||||
if r.URL.Path != "/cli/discovery/apis/bamboo" {
|
||||
t.Fatalf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
payload := ListResponse{
|
||||
@@ -231,7 +231,7 @@ func TestFetchServersFollowsNextCursor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/cli/discovery/apis" {
|
||||
if r.URL.Path != "/cli/discovery/apis/bamboo" {
|
||||
t.Fatalf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+186
-54
@@ -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
|
||||
}
|
||||
@@ -227,7 +258,7 @@ grantType 规则:
|
||||
|
||||
if c != nil && c.DryRun() {
|
||||
if usesPlan {
|
||||
planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, sessionID, true)
|
||||
planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, agentCode, sessionID, true)
|
||||
result, err := callPATBatchPlan(cmd.Context(), c, agentCode, sessionID, planArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pat chmod plan failed: %w", 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)
|
||||
@@ -255,7 +284,7 @@ grantType 规则:
|
||||
}
|
||||
|
||||
if usesPlan {
|
||||
planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, sessionID, true)
|
||||
planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, agentCode, sessionID, true)
|
||||
planResult, err := callPATBatchPlan(cmd.Context(), c, agentCode, sessionID, planArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pat chmod plan failed: %w", err)
|
||||
@@ -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,
|
||||
@@ -277,6 +311,7 @@ grantType 规则:
|
||||
"grantType": grantType,
|
||||
}
|
||||
if agentCode != "" {
|
||||
batchArgs["agentCode"] = agentCode
|
||||
toolArgs["agentCode"] = agentCode
|
||||
}
|
||||
if sessionID != "" {
|
||||
@@ -314,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)
|
||||
@@ -387,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) {
|
||||
@@ -413,12 +465,23 @@ 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)
|
||||
})
|
||||
}
|
||||
|
||||
func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool, grantType string, sessionID string, dryRun bool) map[string]any {
|
||||
func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool, grantType string, agentCode string, sessionID string, dryRun bool) map[string]any {
|
||||
args := map[string]any{
|
||||
"scopes": scopes,
|
||||
"productCodes": productCodes,
|
||||
@@ -426,6 +489,9 @@ func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool,
|
||||
"grantType": grantType,
|
||||
"dryRun": dryRun,
|
||||
}
|
||||
if agentCode != "" {
|
||||
args["agentCode"] = agentCode
|
||||
}
|
||||
if sessionID != "" {
|
||||
args["sessionId"] = sessionID
|
||||
}
|
||||
@@ -481,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
|
||||
@@ -490,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
|
||||
}
|
||||
}
|
||||
@@ -501,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
|
||||
|
||||
+768
-26
@@ -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,18 +403,68 @@ 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("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)
|
||||
}
|
||||
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 _, ok := fake.calls[1].args["agentCode"]; ok {
|
||||
t.Fatalf("batch grant args must not contain agentCode: %#v", fake.calls[1].args)
|
||||
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("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"]}}`,
|
||||
@@ -325,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)
|
||||
@@ -336,15 +485,424 @@ 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].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].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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) {
|
||||
@@ -369,9 +927,99 @@ 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("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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSessionIDFromEnvMatchesHeaderPriority(t *testing.T) {
|
||||
@@ -395,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}
|
||||
@@ -404,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)
|
||||
}
|
||||
@@ -416,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}
|
||||
@@ -426,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)
|
||||
}
|
||||
@@ -446,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)
|
||||
@@ -511,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")
|
||||
|
||||
@@ -530,8 +1187,8 @@ func TestChmod_agentCode_env_fallback(t *testing.T) {
|
||||
if got := fake.gotAgentEnv; got != "qoderwork" {
|
||||
t.Fatalf("agent env = %q, want %q", got, "qoderwork")
|
||||
}
|
||||
if _, ok := fake.gotArgs["agentCode"]; ok {
|
||||
t.Fatalf("batch argv must not carry agentCode identity field: %#v", fake.gotArgs)
|
||||
if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
|
||||
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"})
|
||||
@@ -541,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)
|
||||
@@ -554,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,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) {
|
||||
@@ -801,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"),
|
||||
@@ -813,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 {
|
||||
@@ -965,35 +1697,36 @@ func TestChmod_agentCode_flag_wins_over_env(t *testing.T) {
|
||||
if got := fake.gotAgentEnv; got != "flagval" {
|
||||
t.Fatalf("agent env = %q, want %q (flag must win over env)", got, "flagval")
|
||||
}
|
||||
if _, ok := fake.gotArgs["agentCode"]; ok {
|
||||
t.Fatalf("batch argv must not carry agentCode identity field: %#v", fake.gotArgs)
|
||||
if got := fake.gotArgs["agentCode"]; got != "flagval" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1034,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
@@ -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 开关:
|
||||
|
||||
@@ -39,7 +39,7 @@ personas:
|
||||
- name: ops-analyst
|
||||
title: 运营分析师
|
||||
description: "围绕数据与运营流程执行:表格、听记、邮件与应用协同。"
|
||||
services: [aitable, minutes, mail, aiapp, drive, doc]
|
||||
services: [aitable, minutes, mail, drive, doc]
|
||||
workflows: [minutes-to-todo, drive-upload-and-announce]
|
||||
instructions:
|
||||
- "先获取原始数据,再做结构化汇总与分发。"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,12 @@ set -eu
|
||||
# Environment variables (optional):
|
||||
# DWS_VERSION — release tag (default: latest)
|
||||
# DWS_SKILLS_ROOT — base path for agent dirs (default: $PWD)
|
||||
# DWS_GITEE_REPO — "owner/repo" on Gitee; resolve version + assets via the
|
||||
# Gitee API instead of GitHub (China mirror)
|
||||
|
||||
REPO="DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
# China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via Gitee API.
|
||||
GITEE_REPO="${DWS_GITEE_REPO:-}"
|
||||
VERSION="${DWS_VERSION:-latest}"
|
||||
SKILL_NAME="dws"
|
||||
ROOT="${DWS_SKILLS_ROOT:-$PWD}"
|
||||
@@ -29,10 +33,35 @@ need_cmd() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Fetch a Gitee API endpoint, retrying transient 502/503 from Gitee's gateway.
|
||||
gitee_api() {
|
||||
_url="$1"
|
||||
_try=1
|
||||
while [ "$_try" -le 4 ]; do
|
||||
if _resp="$(curl -fsSL "$_url" 2>/dev/null)" && [ -n "$_resp" ]; then
|
||||
printf '%s' "$_resp"
|
||||
return 0
|
||||
fi
|
||||
_try=$((_try + 1))
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_version() {
|
||||
if [ "$VERSION" = "latest" ]; then
|
||||
VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
|
||||
| grep -i '^location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')"
|
||||
if [ -n "$GITEE_REPO" ]; then
|
||||
# Gitee's /releases/latest and /releases endpoints are unreliable, so
|
||||
# resolve the newest vN.N.N tag from the git tags endpoint instead.
|
||||
VERSION="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" \
|
||||
| grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
|
||||
| sed 's/.*"name":[ ]*"//;s/"$//' \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| sort -V | tail -1)"
|
||||
else
|
||||
VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
|
||||
| grep -i '^location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')"
|
||||
fi
|
||||
if [ -z "$VERSION" ]; then
|
||||
printf '❌ Could not determine the latest version. Set DWS_VERSION explicitly.\n' >&2
|
||||
exit 1
|
||||
@@ -40,6 +69,20 @@ resolve_version() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Resolve a release asset's download URL by name (GitHub template vs Gitee API).
|
||||
asset_url() {
|
||||
_name="$1"
|
||||
if [ -z "$GITEE_REPO" ]; then
|
||||
printf '%s' "https://github.com/${REPO}/releases/download/${VERSION}/${_name}"
|
||||
return 0
|
||||
fi
|
||||
gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/releases/tags/${VERSION}" \
|
||||
| tr '}' '\n' \
|
||||
| grep "\"name\":[ ]*\"${_name}\"" \
|
||||
| grep -o '"browser_download_url":[ ]*"[^"]*"' \
|
||||
| head -1 | sed 's/.*"browser_download_url":[ ]*"//;s/"$//'
|
||||
}
|
||||
|
||||
extract_zip() {
|
||||
archive="$1"
|
||||
dest="$2"
|
||||
@@ -166,7 +209,8 @@ main() {
|
||||
TMPDIR_WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMPDIR_WORK"' EXIT INT TERM
|
||||
|
||||
ASSET_URL="https://github.com/${REPO}/releases/download/${VERSION}/dws-skills.zip"
|
||||
ASSET_URL="$(asset_url dws-skills.zip)"
|
||||
[ -n "$ASSET_URL" ] || { printf '❌ Could not resolve download URL for dws-skills.zip (version %s).\n' "$VERSION" >&2; exit 1; }
|
||||
printf ' ⬇ Downloading skills from GitHub Releases: %s (%s)\n' "$REPO" "$VERSION"
|
||||
curl -fsSL "$ASSET_URL" -o "$TMPDIR_WORK/dws-skills.zip"
|
||||
extract_zip "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK/extracted"
|
||||
|
||||
+48
-3
@@ -18,6 +18,8 @@
|
||||
# DWS_NO_SKILLS — set to 1 to skip skills install
|
||||
# DWS_SKILLS_ONLY — set to 1 to install only skills
|
||||
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else mono)
|
||||
# DWS_GITEE_REPO — "owner/repo" on Gitee; resolve version + assets via the
|
||||
# Gitee API instead of GitHub (China mirror)
|
||||
#
|
||||
# Agent skills paths follow build/npm/install.js AGENT_DIRS (order and entries must match).
|
||||
|
||||
@@ -25,6 +27,8 @@ $ErrorActionPreference = "Stop"
|
||||
|
||||
$Repo = "DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
$BinName = "dws"
|
||||
# China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via the Gitee API.
|
||||
$GiteeRepo = if ($env:DWS_GITEE_REPO) { $env:DWS_GITEE_REPO } else { "" }
|
||||
$InstallDir = if ($env:DWS_INSTALL_DIR) { $env:DWS_INSTALL_DIR } else { Join-Path $HOME ".local\bin" }
|
||||
$Version = if ($env:DWS_VERSION) { $env:DWS_VERSION } else { "latest" }
|
||||
$NoSkills = $env:DWS_NO_SKILLS -eq "1"
|
||||
@@ -118,8 +122,47 @@ function Get-Arch {
|
||||
Write-Err "Unsupported architecture: Could not detect system architecture. Please set DWS_ARCH environment variable to 'amd64' or 'arm64'."
|
||||
}
|
||||
|
||||
function Invoke-GiteeApi {
|
||||
param([string]$Uri)
|
||||
# Gitee's gateway returns sporadic 502/503, so retry a few times before failing.
|
||||
for ($i = 1; $i -le 4; $i++) {
|
||||
try {
|
||||
return Invoke-RestMethod -Uri $Uri -UseBasicParsing
|
||||
} catch {
|
||||
if ($i -eq 4) { throw }
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-GiteeAssetUrl {
|
||||
param([string]$Name)
|
||||
# Resolve a release asset's download URL by name via the Gitee API
|
||||
# (Gitee attachment URLs carry an unstable numeric id, so never template them).
|
||||
$rel = Invoke-GiteeApi "https://gitee.com/api/v5/repos/$GiteeRepo/releases/tags/$Version"
|
||||
foreach ($a in $rel.assets) {
|
||||
if ($a.name -eq $Name) { return $a.browser_download_url }
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function Resolve-LatestVersion {
|
||||
if ($Version -eq "latest") {
|
||||
if ($GiteeRepo -ne "") {
|
||||
try {
|
||||
# Gitee's /releases/latest and /releases endpoints are unreliable
|
||||
# (404 / empty even when releases exist), so resolve the newest
|
||||
# vN.N.N tag from the git tags endpoint instead.
|
||||
$tags = Invoke-GiteeApi "https://gitee.com/api/v5/repos/$GiteeRepo/tags"
|
||||
$latest = $tags.name |
|
||||
Where-Object { $_ -match '^v\d+\.\d+\.\d+$' } |
|
||||
ForEach-Object { [version]($_.TrimStart('v')) } |
|
||||
Sort-Object | Select-Object -Last 1
|
||||
if ($latest) { $script:Version = "v$latest"; return }
|
||||
} catch {}
|
||||
Write-Err "Could not determine the latest Gitee version. Set `$env:DWS_VERSION explicitly."
|
||||
return
|
||||
}
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri "https://github.com/$Repo/releases/latest" `
|
||||
-MaximumRedirection 0 -ErrorAction SilentlyContinue -UseBasicParsing 2>$null
|
||||
@@ -289,7 +332,8 @@ function Install-Binary {
|
||||
Resolve-LatestVersion
|
||||
|
||||
$archiveName = "${BinName}-windows-${arch}.zip"
|
||||
$downloadUrl = "https://github.com/$Repo/releases/download/$Version/$archiveName"
|
||||
if ($GiteeRepo -ne "") { $downloadUrl = Get-GiteeAssetUrl $archiveName } else { $downloadUrl = "https://github.com/$Repo/releases/download/$Version/$archiveName" }
|
||||
if (-not $downloadUrl) { Write-Err "Could not resolve download URL for $archiveName (version $Version)." }
|
||||
|
||||
Write-Say "⬇ Downloading $BinName $Version (windows/$arch)..."
|
||||
|
||||
@@ -301,7 +345,7 @@ function Install-Binary {
|
||||
Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath -UseBasicParsing
|
||||
|
||||
# Download and verify SHA256 checksum
|
||||
$checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt"
|
||||
if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" }
|
||||
try {
|
||||
$checksumPath = Join-Path $tmpDir "checksums.txt"
|
||||
Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing
|
||||
@@ -483,7 +527,8 @@ function Install-Skills {
|
||||
Write-Say "📦 Installing agent skills from GitHub Releases..."
|
||||
Resolve-LatestVersion
|
||||
|
||||
$zipUrl = "https://github.com/$Repo/releases/download/$Version/dws-skills.zip"
|
||||
if ($GiteeRepo -ne "") { $zipUrl = Get-GiteeAssetUrl "dws-skills.zip" } else { $zipUrl = "https://github.com/$Repo/releases/download/$Version/dws-skills.zip" }
|
||||
if (-not $zipUrl) { Write-Err "Could not resolve download URL for dws-skills.zip (version $Version)." }
|
||||
|
||||
$tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "dws-skills-$PID"
|
||||
New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null
|
||||
|
||||
+59
-5
@@ -15,6 +15,8 @@
|
||||
# DWS_NO_SKILLS — set to 1 to skip skills install
|
||||
# DWS_SKILLS_ONLY — set to 1 to install only skills (skip binary)
|
||||
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else mono)
|
||||
# DWS_GITEE_REPO — "owner/repo" on Gitee; when set, version + assets resolve
|
||||
# via the Gitee API instead of GitHub (China mirror)
|
||||
#
|
||||
# Agent skills paths follow build/npm/install.js AGENT_DIRS (order and entries must match).
|
||||
|
||||
@@ -22,6 +24,10 @@ set -eu
|
||||
|
||||
REPO="DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
BIN_NAME="dws"
|
||||
# China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via
|
||||
# the Gitee API (https://gitee.com/api/v5) instead of GitHub. Gitee attachment URLs
|
||||
# carry an unstable numeric id, so every asset is resolved by name at install time.
|
||||
GITEE_REPO="${DWS_GITEE_REPO:-}"
|
||||
INSTALL_DIR="${DWS_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
INSTALL_NAME="${DWS_INSTALL_NAME:-$BIN_NAME}"
|
||||
VERSION="${DWS_VERSION:-latest}"
|
||||
@@ -77,6 +83,42 @@ download() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Fetch a Gitee API endpoint, retrying transient failures. Gitee's gateway
|
||||
# returns sporadic 502/503, so a single curl is unreliable; retry a few times
|
||||
# before giving up. Prints the response body on success.
|
||||
gitee_api() {
|
||||
_url="$1"
|
||||
_try=1
|
||||
while [ "$_try" -le 4 ]; do
|
||||
if _resp="$(curl -fsSL "$_url" 2>/dev/null)" && [ -n "$_resp" ]; then
|
||||
printf '%s' "$_resp"
|
||||
return 0
|
||||
fi
|
||||
_try=$((_try + 1))
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Resolve the download URL of a release asset by file name.
|
||||
# GitHub: deterministic template <repo>/releases/download/<version>/<file>.
|
||||
# Gitee : query the release API and pick the asset whose name matches
|
||||
# (Gitee attachment URLs carry an unstable numeric id, so we never
|
||||
# template them — we read browser_download_url straight from the API).
|
||||
asset_url() {
|
||||
_name="$1"
|
||||
if [ -z "$GITEE_REPO" ]; then
|
||||
printf '%s' "https://github.com/${REPO}/releases/download/${VERSION}/${_name}"
|
||||
return 0
|
||||
fi
|
||||
gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/releases/tags/${VERSION}" \
|
||||
| tr '}' '\n' \
|
||||
| grep "\"name\":[ ]*\"${_name}\"" \
|
||||
| grep -o '"browser_download_url":[ ]*"[^"]*"' \
|
||||
| head -1 \
|
||||
| sed 's/.*"browser_download_url":[ ]*"//;s/"$//'
|
||||
}
|
||||
|
||||
extract_zip() {
|
||||
archive="$1"
|
||||
dest="$2"
|
||||
@@ -114,8 +156,18 @@ detect_arch() {
|
||||
# Resolve the latest version tag from GitHub
|
||||
resolve_version() {
|
||||
if [ "$VERSION" = "latest" ]; then
|
||||
# Follow the redirect from /releases/latest to get the tag
|
||||
if need_cmd curl; then
|
||||
if [ -n "$GITEE_REPO" ]; then
|
||||
# Gitee's /releases/latest and /releases endpoints are unreliable — they
|
||||
# return 404 / an empty list even when releases exist — so resolve the
|
||||
# newest version from the git tags endpoint instead: keep only vN.N.N
|
||||
# tags and pick the highest by version order.
|
||||
VERSION="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" \
|
||||
| grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
|
||||
| sed 's/.*"name":[ ]*"//;s/"$//' \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| sort -V | tail -1)"
|
||||
elif need_cmd curl; then
|
||||
# Follow the redirect from /releases/latest to get the tag
|
||||
VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
|
||||
| grep -i '^location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')"
|
||||
elif need_cmd wget; then
|
||||
@@ -401,7 +453,8 @@ install_binary() {
|
||||
resolve_version
|
||||
|
||||
archive_name="${BIN_NAME}-${os}-${arch}.tar.gz"
|
||||
download_url="https://github.com/${REPO}/releases/download/${VERSION}/${archive_name}"
|
||||
download_url="$(asset_url "$archive_name")"
|
||||
[ -n "$download_url" ] || err "Could not resolve download URL for ${archive_name} (version ${VERSION})."
|
||||
|
||||
say "⬇ Downloading ${BIN_NAME} ${VERSION} (${os}/${arch})..."
|
||||
|
||||
@@ -411,7 +464,7 @@ install_binary() {
|
||||
download "$download_url" "$tmpdir/$archive_name"
|
||||
|
||||
# Download and verify SHA256 checksum
|
||||
checksum_url="https://github.com/${REPO}/releases/download/${VERSION}/checksums.txt"
|
||||
checksum_url="$(asset_url checksums.txt)"
|
||||
if download "$checksum_url" "$tmpdir/checksums.txt" 2>/dev/null; then
|
||||
expected="$(awk -v file="$archive_name" '$2 == file {print $1; exit}' "$tmpdir/checksums.txt")"
|
||||
if [ -n "$expected" ]; then
|
||||
@@ -482,7 +535,8 @@ install_skills() {
|
||||
|
||||
resolve_version
|
||||
skills_archive="dws-skills.zip"
|
||||
download_url="https://github.com/${REPO}/releases/download/${VERSION}/${skills_archive}"
|
||||
download_url="$(asset_url "$skills_archive")"
|
||||
[ -n "$download_url" ] || err "Could not resolve download URL for ${skills_archive} (version ${VERSION})."
|
||||
|
||||
tmpdir_skills="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmpdir_skills"' EXIT INT TERM
|
||||
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright 2026 Alibaba Group
|
||||
# Licensed under the Apache License, Version 2.0
|
||||
#
|
||||
# Mirror release artifacts to a Gitee release so China users can install without
|
||||
# hitting GitHub. The repo code itself is kept in sync by Gitee's repo-mirror
|
||||
# feature; this script handles what the mirror does NOT carry — the GitHub
|
||||
# Release *attachments* (binaries, checksums, skills zip) — by uploading them to
|
||||
# the matching Gitee release via the Gitee OpenAPI v5.
|
||||
#
|
||||
# Consumed by install.sh when DWS_GITEE_REPO is set (it resolves each asset's
|
||||
# real download_url from the Gitee API, since Gitee attachment URLs carry an
|
||||
# unstable numeric id).
|
||||
#
|
||||
# Required environment (CI secrets):
|
||||
# GITEE_TOKEN Gitee private access token (scopes: projects)
|
||||
# GITEE_REPO "owner/repo" on Gitee, e.g. DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
# Optional:
|
||||
# VERSION release tag (default: git describe)
|
||||
# DIST_DIR artifacts dir (default: ./dist)
|
||||
# GITEE_API API base (default: https://gitee.com/api/v5)
|
||||
#
|
||||
# Gating: if GITEE_TOKEN / GITEE_REPO are unset, exit 0 with a notice so the
|
||||
# step can live in release.yml without breaking forks that lack the secret.
|
||||
|
||||
set -eu
|
||||
|
||||
DIST_DIR="${DIST_DIR:-dist}"
|
||||
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
|
||||
|
||||
missing=""
|
||||
[ -z "${GITEE_TOKEN:-}" ] && missing="$missing GITEE_TOKEN"
|
||||
[ -z "${GITEE_REPO:-}" ] && missing="$missing GITEE_REPO"
|
||||
if [ -n "$missing" ]; then
|
||||
echo "ℹ️ Gitee mirror sync skipped — missing:${missing}"
|
||||
echo " Set these as repo secrets to auto-mirror releases to Gitee for China users."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}"
|
||||
OWNER="${GITEE_REPO%%/*}"
|
||||
NAME="${GITEE_REPO##*/}"
|
||||
base="${GITEE_API}/repos/${OWNER}/${NAME}"
|
||||
|
||||
echo "📦 Mirroring release ${VERSION} → Gitee ${GITEE_REPO}"
|
||||
|
||||
# ── Resolve or create the Gitee release for this tag ──────────────────────────
|
||||
# Gitee mirror sync brings the git tag over, so the tag should already exist.
|
||||
rel_json="$(curl -fsSL "${base}/releases/tags/${VERSION}?access_token=${GITEE_TOKEN}" 2>/dev/null || true)"
|
||||
release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
|
||||
|
||||
if [ -z "$release_id" ]; then
|
||||
echo " No Gitee release for ${VERSION} yet — creating it."
|
||||
rel_json="$(curl -fsSL -X POST "${base}/releases" \
|
||||
-F "access_token=${GITEE_TOKEN}" \
|
||||
-F "tag_name=${VERSION}" \
|
||||
-F "name=${VERSION}" \
|
||||
-F "body=Mirror of GitHub release ${VERSION} for China users." \
|
||||
-F "target_commitish=main" 2>/dev/null || true)"
|
||||
release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
|
||||
fi
|
||||
[ -n "$release_id" ] || { echo "❌ Could not get/create Gitee release for ${VERSION}. Response: ${rel_json}" >&2; exit 1; }
|
||||
echo " Gitee release id = ${release_id}"
|
||||
|
||||
# ── Upload each artifact as a release attachment ──────────────────────────────
|
||||
uploaded=0
|
||||
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do
|
||||
[ -f "$f" ] || continue
|
||||
fn="$(basename "$f")"
|
||||
echo " ⬆ ${fn}"
|
||||
resp="$(curl -fsSL -X POST "${base}/releases/${release_id}/attach_files" \
|
||||
-F "access_token=${GITEE_TOKEN}" \
|
||||
-F "file=@${f}" 2>/dev/null || true)"
|
||||
if printf '%s' "$resp" | grep -q '"browser_download_url"'; then
|
||||
uploaded=$((uploaded + 1))
|
||||
else
|
||||
echo " ⚠ upload may have failed for ${fn}: ${resp}" >&2
|
||||
fi
|
||||
done
|
||||
|
||||
[ "$uploaded" -gt 0 ] || { echo "❌ No artifacts uploaded. Did the build (goreleaser) run?" >&2; exit 1; }
|
||||
echo "✅ Uploaded ${uploaded} asset(s) to Gitee release ${VERSION}."
|
||||
echo " China install: DWS_GITEE_REPO=${GITEE_REPO} \\"
|
||||
echo " curl -fsSL https://gitee.com/${GITEE_REPO}/raw/main/scripts/install.sh | sh"
|
||||
@@ -35,7 +35,6 @@ cli_version: ">=1.0.15"
|
||||
|
||||
| 产品 | 用途 | 参考文件 |
|
||||
|-------------------|------------------------------------------------------|----------------------------------------------------------------|
|
||||
| `aiapp` | AI应用:创建/查询/修改AI应用 | [aiapp.md](./references/products/aiapp.md) |
|
||||
| `aisearch` | AI搜问(搜人首选):按姓名/部门/职位/职责/上级/下级/手机号/工号维度找人,"谁负责 XX/XX 的负责人/某事项/某项目的人"统一走本产品 | [aisearch.md](./references/products/aisearch.md) |
|
||||
| `aitable` | AI表格:Base/数据表/字段/记录/视图/附件/图表/仪表盘/导入导出/模板搜索 | [aitable.md](./references/products/aitable.md) |
|
||||
| `attendance` | 考勤:打卡结果/打卡流水/考勤组查询/考勤规则/汇总统计/假期类型/假期余额(P0 已落地,部分管理类命令仍属 P1) | [attendance.md](./references/products/attendance.md) |
|
||||
@@ -83,7 +82,6 @@ cli_version: ">=1.0.15"
|
||||
|
||||
## 意图判断决策树
|
||||
|
||||
用户提到"AI应用/创建应用/生成系统/做工具/管理后台/低代码" → `aiapp`
|
||||
用户提到"找人/搜人/谁负责 XX/某事项的负责人/某项目的人/团队成员/上级/下级/按工号找人/按手机号找人" → `aisearch`
|
||||
用户提到"表格/多维表/AI表格/记录/数据/视图/图表/仪表盘" → `aitable`
|
||||
用户提到"考勤/打卡/排班" → `attendance`
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
| Recipe | 行动指南(固定路线) |
|
||||
|--------|-------------------|
|
||||
| query-group-chat | **优先**:`chat_export_messages.py`(开源版未引入;可手动用 `dws chat message list` 翻页后写入文件)(自动搜群+翻页+导出)<br>备选:1. `chat search --query "<群名>"` → 取 `openConversationId`<br>2. `chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:`hasMore=true` 时取本页最后 `createTime` 作为下次 `--time`,重复至 `hasMore=false`<br>4. `--forward=false` 拉给定时间**之前**的消息<br>5. 合并全部消息后总结 |
|
||||
| query-private-chat | **优先**:`chat_history_with_user.py`(开源版未引入;可手动用 `dws chat search` + `dws chat message list` 组合)(自动搜人+翻页+导出)<br>备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>2. `chat message list --user <userId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:同 query-group-chat<br>4. 合并全部消息后总结 |
|
||||
| query-private-chat | **优先**:`chat_history_with_user.py`(开源版未引入;可手动用 `aisearch person` + `dws chat message list-direct` 组合)(自动搜人+翻页+导出)<br>备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>2. `chat message list-direct --user <userId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表(单聊专用;旧版 `list --user` 已停用)<br>3. **翻页**:同 query-group-chat<br>4. 合并全部消息后总结 |
|
||||
| escalate-ding | 三级升级:<br>1. `ding message send --robot-code <robotCode> --type app --users <userId> --content "<内容>"`(必填项见 [ding.md](../products/ding.md))<br>2. `chat message send --group <openConversationId> --text "<内容>"` 群里提醒(可选 `--title` / `@` 见 [chat.md](../products/chat.md))<br>3. `todo task create --title "<标题>" --executors <userId> --priority 40` 建紧急待办<br>前置:`aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`;`chat search --query "<群名>"` → 取 `openConversationId` |
|
||||
| send-by-bot | **多群批量优先**:`bot_broadcast.py`(开源版未引入;可手动用 `dws chat message send-by-bot` 多次调用)<br>单群:1. `chat bot search` → 取 `robotCode`<br>2. `chat search --query "<群名>"` → 取 `openConversationId`<br>3. `chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title "<标题>" --text "<内容>"` |
|
||||
| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message list --group <openConversationId> --time "<起始时间>"` 拉源消息<br>2. `contact user search --query "<姓名>"` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`<br>3. `chat message send --open-dingtalk-id <openDingTalkId> --text "<内容>"`(推荐)或 `--group <openConversationId> --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user <userId>`(备选) |
|
||||
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,420 +0,0 @@
|
||||
# AI应用 (aiapp) 命令参考
|
||||
|
||||
## 强制触发规则
|
||||
|
||||
以下意图默认必须优先使用 `aiapp`,即使用户没有说“AI应用”:
|
||||
|
||||
- 创建一个新应用
|
||||
- 创建一个新系统
|
||||
- 生成一个业务工具
|
||||
- 按需求生成一个管理后台、工作台应用、表单系统、业务原型
|
||||
- 修改一个已经生成过的应用
|
||||
|
||||
典型触发词包括但不限于:
|
||||
|
||||
- 应用、系统、平台、工具、后台、页面、原型、工作台应用
|
||||
- CRM、OA、库存、仓库、审批、销售、报销、进销存、客户管理、数据录入、查询工具
|
||||
- 计算器、待办工具、表单应用、信息收集应用、报表应用
|
||||
- “帮我做一个…”
|
||||
- “帮我创建一个…”
|
||||
- “按这个需求生成一个…”
|
||||
- “把这个需求做成应用”
|
||||
|
||||
典型例子:
|
||||
|
||||
- “帮我创建个简易的计算器应用”
|
||||
- “做一个仓库管理系统”
|
||||
- “生成一个客户管理工具”
|
||||
- “按这个 PRD 创建一个钉钉应用”
|
||||
- “修改刚才那个应用,把首页改得更简洁”
|
||||
|
||||
不要误路由到其他产品:
|
||||
|
||||
- 不要因为用户提到“表格/记录/字段”,就优先走 `aitable`,如果用户的真实目标是“生成一个完整应用”
|
||||
- 不要因为用户提到“文档/需求”,就优先走 `doc`,如果用户的真实目标是“把需求做成应用”
|
||||
- 不要因为用户提到“任务/项目”,就优先走 `tb`,如果用户的真实目标是“生成一个管理系统”
|
||||
|
||||
只有在以下场景不要走 `aiapp`:
|
||||
|
||||
- 用户明确要操作一个现成的多维表、文档、项目、日历、机器人消息
|
||||
- 用户不是要“生成应用”,而是要“修改已有产品对象”
|
||||
|
||||
## 命令总览
|
||||
|
||||
### 创建 AI 应用
|
||||
```
|
||||
Usage:
|
||||
dws aiapp create [flags]
|
||||
Example:
|
||||
dws aiapp create --prompt "创建一个天气查询应用"
|
||||
dws aiapp create --prompt "翻译应用" --skills s1,s2
|
||||
dws aiapp create --prompt "根据附件里的 Excel 创建一个仓库管理应用" --attachments '[{"name":"warehouse.xlsx","type":"excel","url":"https://tmp/warehouse.xlsx"}]'
|
||||
Flags:
|
||||
--prompt string 创建 AI 应用的 prompt (必填)
|
||||
--skills string 技能 ID 列表,逗号分隔
|
||||
--attachments string 附件对象数组的 JSON 字符串,用于把 Excel/图片等输入材料一起传入
|
||||
```
|
||||
|
||||
### 查询 AI 应用
|
||||
```
|
||||
Usage:
|
||||
dws aiapp query [flags]
|
||||
Example:
|
||||
dws aiapp query --task-id <taskId>
|
||||
Flags:
|
||||
--task-id string AI 应用任务 ID (必填)
|
||||
```
|
||||
|
||||
### 修改 AI 应用
|
||||
```
|
||||
Usage:
|
||||
dws aiapp modify [flags]
|
||||
Example:
|
||||
dws aiapp modify --prompt "改为翻译应用" --thread-id <threadId>
|
||||
dws aiapp modify --prompt "新描述" --thread-id <threadId> --skills s1,s2
|
||||
dws aiapp modify --prompt "根据新图片优化首页视觉风格" --thread-id <threadId> --attachments '[{"name":"homepage.png","type":"image/png","url":"https://tmp/homepage.png"}]'
|
||||
Flags:
|
||||
--prompt string 新的 prompt (必填)
|
||||
--skills string 技能 ID 列表,逗号分隔
|
||||
--thread-id string threadId (必填)
|
||||
--attachments string 附件对象数组的 JSON 字符串;当前 modify 仅支持图片附件,不支持 Excel
|
||||
```
|
||||
|
||||
## 三个动作的关系
|
||||
|
||||
`aiapp` 的三个动作不是彼此独立的,它们描述的是同一个应用任务的生命周期:
|
||||
|
||||
1. `create` 用来发起一次新的应用生成任务,返回 `taskId` 和 `threadId`
|
||||
2. `query` 用来跟踪这次任务是否完成,以及读取生成结果
|
||||
3. `modify` 用来基于同一个 `threadId` 继续和已生成的应用对话,本质上是对已有应用做增量修改
|
||||
|
||||
可以把它理解为:
|
||||
|
||||
- `create` = 新建一个应用会话
|
||||
- `query` = 查看这个会话对应任务的执行状态和结果
|
||||
- `modify` = 在这个会话里继续追加新的需求,让同一个应用继续演化
|
||||
|
||||
关键标识符的关系:
|
||||
|
||||
- `taskId` 表示某一次具体的执行任务,主要给 `query` 使用
|
||||
- `threadId` 表示这个应用的持续会话,主要给 `modify` 使用
|
||||
- 一次 `create` 会产生一个新的 `taskId`,同时也会产出或确认一个 `threadId`
|
||||
- 后续每次 `modify` 都是在同一个 `threadId` 上发起新的任务,因此也会产生新的 `taskId`
|
||||
|
||||
因此推荐把它们看成一条闭环链路,而不是三个平级命令:
|
||||
|
||||
`create -> query -> modify -> query -> modify ...`
|
||||
|
||||
## `attachments` 的含义
|
||||
|
||||
`attachments` 是 `aiapp` 的输入材料参数,用来把文件和当前任务一起提交给 `aiapp`。
|
||||
|
||||
它适合这些场景:
|
||||
|
||||
- 根据 Excel 创建应用
|
||||
- 根据需求文档、表格、截图继续完善应用
|
||||
- 在修改已有应用时补充新的结构化材料
|
||||
|
||||
可以把它理解为:
|
||||
|
||||
- `prompt` 描述“希望系统做什么”
|
||||
- `attachments` 提供“系统可直接参考的文件材料”
|
||||
|
||||
### 正确心智
|
||||
|
||||
- `attachments` 是 `create` 或 `modify` 的补充输入,不是独立动作
|
||||
- 即使提供了附件,主动作仍然是 `aiapp create` 或 `aiapp modify`
|
||||
- 当用户说“按这个 Excel 建一个应用”时,优先走 `aiapp`,并把 Excel 作为 `attachments` 传入
|
||||
|
||||
### 数据结构
|
||||
|
||||
`attachments` 不是单个字符串,而是对象数组。每个对象当前按以下结构传递:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "warehouse.xlsx",
|
||||
"type": "excel",
|
||||
"url": "https://tmp/warehouse.xlsx",
|
||||
"size": 102400
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
每个附件对象当前可用字段:
|
||||
|
||||
- `name`:文件名
|
||||
- `type`:文件类型或 MIME 类型
|
||||
- `url`:可直接访问的临时下载地址
|
||||
- `size`:文件大小,可选
|
||||
|
||||
如果要传多个附件,就在数组中放多个对象。
|
||||
|
||||
### Excel 场景
|
||||
|
||||
如果用户上传了 Excel,通常应该:
|
||||
|
||||
- 在 `prompt` 里明确写“根据附件中的 Excel 创建应用”或“根据附件继续修改应用”
|
||||
- 同时把 Excel 通过 `attachments` 传给 `aiapp`
|
||||
|
||||
这类需求都应优先视为 `aiapp`:
|
||||
|
||||
- “根据这个 Excel 建一个库存管理应用”
|
||||
- “把附件里的表格做成一个 CRM 应用”
|
||||
- “按这个 Excel 继续补充刚才那个应用”
|
||||
|
||||
### 使用规则
|
||||
|
||||
- `create` 可以带图片、Excel 等附件
|
||||
- `modify` 当前只建议带图片附件,不支持 Excel
|
||||
- `query` 不使用 `attachments`
|
||||
- 附件应按对象数组传递,而不是传单个 ID 或随意文本
|
||||
- 如果用户提供本地文件 + 任意意图 → 先上传文件获取 URL,再执行对应命令
|
||||
- 每个附件对象通常至少应包含可访问的临时下载地址 `url`
|
||||
- 如果有附件但没有特别完整的 prompt,也仍然建议补一句明确意图,避免模型误判
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 根据 Excel 创建应用
|
||||
dws aiapp create --prompt "根据附件里的 Excel 创建一个仓库管理应用" \
|
||||
--attachments '[{"name":"warehouse.xlsx","type":"excel","url":"https://tmp/warehouse.xlsx"}]' \
|
||||
--format json
|
||||
|
||||
# 在已有应用上继续结合图片修改
|
||||
dws aiapp modify --prompt "根据新图片优化首页布局和视觉风格" \
|
||||
--thread-id <threadId> \
|
||||
--attachments '[{"name":"homepage.png","type":"image/png","url":"https://tmp/homepage.png"}]' \
|
||||
--format json
|
||||
```
|
||||
|
||||
## `--skills` 的含义
|
||||
|
||||
`--skills` 是 `aiapp` 的附加能力参数,不是一个独立动作。
|
||||
|
||||
它的作用不是“先执行这些技能,再单独执行 `aiapp`”,而是:
|
||||
|
||||
- 在 `create` 时,把这些官方技能作为本次应用生成任务的附加约束或附加能力一起传入
|
||||
- 在 `modify` 时,把这些官方技能作为本次修改任务的附加约束或附加能力一起传入
|
||||
|
||||
可以把它理解为:
|
||||
|
||||
- `prompt` 描述“你想做什么应用”
|
||||
- `--skills` 描述“这次生成或修改时,要额外挂载哪些官方能力或规范”
|
||||
|
||||
因此 `--skills` 必须依附于 `create` 或 `modify` 使用,本身不能单独完成创建应用。
|
||||
|
||||
### 正确心智
|
||||
|
||||
- `aiapp` 是主动作
|
||||
- `--skills` 是主动作的附加参数
|
||||
- 用户是否挂载官方技能,会影响应用生成或修改效果
|
||||
- 即使用户提到了某个官方技能,最终仍然应该优先调用 `aiapp create` 或 `aiapp modify`
|
||||
|
||||
### 常见误区
|
||||
|
||||
不要把以下场景理解错:
|
||||
|
||||
- 用户说“创建一个应用,并遵循设计规范”
|
||||
- 正确做法:调用 `aiapp create ... --skills <design-skill-id>`
|
||||
- 不正确做法:把“设计规范技能”当成一个和 `aiapp` 平级、单独执行的动作
|
||||
|
||||
- 用户说“继续修改刚才那个应用,并带上设计规范”
|
||||
- 正确做法:调用 `aiapp modify ... --thread-id <threadId> --skills <design-skill-id>`
|
||||
- 不正确做法:先执行设计规范技能,再单独调用 `modify`
|
||||
|
||||
- 用户只是在描述“希望页面更漂亮、更标准”
|
||||
- 如果已知对应的官方技能 ID,可以通过 `--skills` 传入
|
||||
- 如果没有明确的技能 ID,不要编造,优先把需求写进 `prompt`
|
||||
|
||||
### 什么时候传 `--skills`
|
||||
|
||||
适合传 `--skills` 的情况:
|
||||
|
||||
- 用户明确要求遵循某个已知的官方能力、官方规范、官方模板
|
||||
- 当前上下文里已经拿到了可用的官方技能 ID/Code
|
||||
- 需要在创建或修改时显式挂载这些技能
|
||||
|
||||
不适合传 `--skills` 的情况:
|
||||
|
||||
- 没有明确的技能 ID
|
||||
- 只是模糊地希望“更专业”“更好看”,但没有对应官方技能可挂
|
||||
- 技能本身不是 `aiapp` 官方子技能,而是其他产品的独立操作能力
|
||||
|
||||
### 使用规则
|
||||
|
||||
- `create` 和 `modify` 都可以带 `--skills`
|
||||
- `query` 不使用 `--skills`
|
||||
- `--skills` 里的值必须是技能 ID,多个值用逗号分隔
|
||||
- 不要猜测、编造技能 ID,必须来自已知上下文或系统返回
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 创建应用,并挂载一个钉钉设计规范技能
|
||||
dws aiapp create --prompt "创建一个仓库管理应用" \
|
||||
--skills dingtalk_design_spec --format json
|
||||
|
||||
# 修改已有应用,并继续带上官方技能
|
||||
dws aiapp modify --prompt "增加个库存大盘图表页" \
|
||||
--thread-id <threadId> \
|
||||
--skills dingtalk_design_spec --format json
|
||||
```
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说以下任何一种,都走 `create`:
|
||||
|
||||
- “做个应用 / 创建应用 / 生成应用 / 搭个应用”
|
||||
- “做个系统 / 平台 / 后台 / 工具 / 页面”
|
||||
- “帮我做个计算器 / 仓库管理 / CRM / OA / 审批 / 报表 / 信息收集应用”
|
||||
- “按这段需求生成一个钉钉应用”
|
||||
|
||||
用户说以下任何一种,都走 `query`:
|
||||
|
||||
- “查看应用状态”
|
||||
- “这个应用创建好了没”
|
||||
- “查一下 taskId 对应的进度”
|
||||
- “继续轮询应用创建进展”
|
||||
|
||||
用户说以下任何一种,都走 `modify`:
|
||||
|
||||
- “修改应用”
|
||||
- “更新刚才那个应用”
|
||||
- “基于这个 thread/task 继续改”
|
||||
- “把首页改一下 / 把功能补上 / 换个风格”
|
||||
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
# 1. 创建 AI 应用 — 提取 taskId 和 threadId
|
||||
dws aiapp create --prompt "创建一个天气查询应用" --format json
|
||||
|
||||
# 2. 查询应用状态
|
||||
dws aiapp query --task-id <taskId> --format json
|
||||
|
||||
# 3. 修改应用
|
||||
dws aiapp modify --prompt "新描述" --thread-id <threadId> \
|
||||
--skills skill1,skill2 --format json
|
||||
```
|
||||
|
||||
补充理解:
|
||||
|
||||
- 如果用户还没有现成应用,先走 `create`
|
||||
- 如果用户刚发起了创建,通常先走 `query` 看是否完成
|
||||
- 如果用户说“继续改刚才那个应用”,优先找上一次返回里的 `threadId`,走 `modify`
|
||||
- `modify` 完成后,仍然可以继续用新的 `taskId` 走 `query`
|
||||
- 不要把 `modify` 理解成“覆盖旧应用”,它更像是在同一个应用会话中追加新需求
|
||||
- `attachments` 不改变 `create/query/modify` 的主流程,它只是给 `create` 或 `modify` 增加文件输入
|
||||
- 如果附件是 Excel,优先理解为 `create` 场景;`modify` 当前不要用 Excel 附件
|
||||
- `--skills` 不改变 `create/query/modify` 的主流程,它只是给 `create` 或 `modify` 增加官方附加能力
|
||||
|
||||
推荐决策顺序:
|
||||
|
||||
1. 先判断用户是不是要“生成一个新的应用/系统”
|
||||
2. 如果是,直接用 `aiapp create`
|
||||
3. 如果用户是在已有应用基础上继续改,用 `aiapp modify`
|
||||
4. 如果只是查进度或结果,用 `aiapp query`
|
||||
|
||||
## 查询与轮询预期
|
||||
|
||||
`aiapp` 的创建和修改通常不是秒级任务,而是分钟级任务。
|
||||
|
||||
- 简单应用通常需要几分钟
|
||||
- 复杂应用可能持续十几分钟到几十分钟
|
||||
|
||||
因此在使用 `query` 时,不要假设“连续几次没有完成”就代表任务卡死。
|
||||
|
||||
### 正确的轮询策略
|
||||
|
||||
- 不要每 5-10 秒高频轮询
|
||||
- 建议以 30 秒为默认轮询间隔
|
||||
- 如果任务已经明确进入长流程,可进一步放宽到 60 秒
|
||||
- 除非用户明确要求实时刷新,否则不要自行创建过于频繁的定时查询
|
||||
|
||||
### 代理行为约束
|
||||
|
||||
当 agent 已经帮用户发起了 `aiapp create` 或 `aiapp modify` 后,不要只查询 1-2 次就把后续进度甩给用户自己处理。
|
||||
|
||||
- 只要任务仍处于 `queued` 或 `running`,且用户没有明确要求停止,agent 应继续主动跟踪进度
|
||||
- 如果当前会话无法持续等待,可以创建定时任务继续查询,但定时任务必须是“以拿到最终结果为目标”的,而不是无条件永久轮询
|
||||
- 如果返回了 `threadViewUrl`,可以告诉用户“你也可以点这个链接查看实时进度”,但这不能替代 agent 自己继续追踪任务
|
||||
|
||||
### 定时任务规则
|
||||
|
||||
如果 agent 选择创建定时任务来继续查询 `aiapp` 状态,必须同时设置结束条件:
|
||||
|
||||
- 当 `status = succeeded` 且已拿到 `appPreviewUrl` 时,立即停止后续查询,并主动删除或关闭该定时任务
|
||||
- 当 `status = failed` 时,立即停止后续查询,并主动删除或关闭该定时任务
|
||||
- 不要在已经拿到目标结果后继续保留定时任务
|
||||
- 不要创建“只会查询、不会自终止”的定时任务
|
||||
|
||||
换句话说,定时任务应当是“查询直到成功或失败,然后自动结束”的一次性追踪任务,而不是长期后台轮询
|
||||
|
||||
### 如何理解 `query` 返回
|
||||
|
||||
`query` 的返回里,除了总状态外,还可能包含进度信息,例如:
|
||||
|
||||
- 当前正在执行哪个步骤
|
||||
- 已完成多少步骤
|
||||
- 最近一次活动时间
|
||||
- 当前步骤列表
|
||||
- 当前 thread 的访问链接
|
||||
|
||||
因此判断任务是否还在推进时,不要只看 `status` 是否还是 `running`,还要结合以下字段一起看:
|
||||
|
||||
- `updatedAt` / `lastActivityAt`
|
||||
- `progress.summary`
|
||||
- `progress.currentStep`
|
||||
- `progress.steps`
|
||||
- `threadViewUrl`
|
||||
|
||||
只要这些字段仍在变化,通常就说明任务仍在正常推进。
|
||||
|
||||
### `threadViewUrl` 与 `appPreviewUrl` 的区别
|
||||
|
||||
`query` 或 `create` 的返回里,可能会有两个不同的链接:
|
||||
|
||||
- `threadViewUrl`:当前 thread 的访问链接。用于打开 `aiapp` 站点里的会话/生成进度页面,适合在应用还没生成完成时查看实时状态
|
||||
- `appPreviewUrl`:最终生成出的应用访问地址。只有应用创建成功后才会出现,适合真正打开应用去使用
|
||||
|
||||
不要把这两个链接混用:
|
||||
|
||||
- 任务还在 `queued/running` 时,优先使用 `threadViewUrl`
|
||||
- 任务 `succeeded` 且结果里已有 `appPreviewUrl` 时,再使用 `appPreviewUrl`
|
||||
|
||||
### 何时停止轮询
|
||||
|
||||
- 当 `status = succeeded` 时,停止轮询,并从结果中提取 `threadId`、应用链接等信息
|
||||
- 当 `status = failed` 时,停止轮询,并向用户报告失败
|
||||
- 当任务长时间保持 `running`,但 `lastActivityAt` 和 `progress` 都没有变化时,再考虑提示可能卡住,而不是过早下结论
|
||||
- 如果此前为查询进度创建了定时任务,在 `succeeded` 或 `failed` 后要主动删除该定时任务,而不是留给用户处理
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `create` | `taskId`, `threadId`, `threadViewUrl` | query 的 --task-id, modify 的 --thread-id,或直接打开进度页 |
|
||||
| `query` | `threadViewUrl`, `appPreviewUrl`, 应用详情 | 查看进度页,或在成功后打开应用预览并决定是否 modify |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `create` 通过自然语言 prompt 描述应用功能,系统自动生成
|
||||
- 对“应用/系统/工具/后台/原型”类需求,优先使用 `aiapp`,不要改用其他产品命令模拟实现
|
||||
- 对“根据附件 / 根据 Excel / 根据上传表格生成应用”这类需求,也优先使用 `aiapp`
|
||||
- `attachments` 需要按对象数组传递,当前结构是 `[{name,type,url,size}]`
|
||||
- `create` 可使用 Excel 附件;`modify` 当前仅支持图片附件,不支持 Excel
|
||||
- `--skills` 传入官方技能 UID 列表,逗号分隔,可增强应用能力
|
||||
- `modify` 需要 `--thread-id` 来标识修改哪个会话线程
|
||||
- 应用未生成完成前,如果需要让用户查看实时状态,优先使用返回里的 `threadViewUrl`
|
||||
- 应用生成成功后,如果需要真正打开应用,再使用 `appPreviewUrl`
|
||||
- 创建后可通过 `query` 轮询 `taskId` 查看创建进度,但轮询间隔默认应为 20-30 秒,不要 10 秒级频繁轮询
|
||||
- agent 不应只查 1-2 次就放弃;如果任务仍在运行,应继续跟踪,或创建带终止条件的定时任务继续跟踪
|
||||
- 如果 agent 创建了用于查询进度的定时任务,在任务成功或失败后必须主动删除或结束该定时任务
|
||||
- 不要直接使用用户本地文件路径作为附件 URL,必须先上传转换为互联网可访问链接
|
||||
|
||||
## 自动化脚本
|
||||
|
||||
| 脚本 | 场景 | 用法 |
|
||||
|------|------|------|
|
||||
| [aiapp_create_and_poll.py](../../scripts/aiapp_create_and_poll.py) | 一键创建 AI 应用并自动轮询进度直到完成 | `python aiapp_create_and_poll.py --prompt "创建一个天气查询应用"` |
|
||||
@@ -338,35 +338,54 @@ Flags:
|
||||
|
||||
### message (会话消息管理)
|
||||
|
||||
#### 拉取会话消息内容 — 拉取指定群聊或单聊的会话消息内容
|
||||
#### 拉取群聊会话消息内容 — 拉取指定群聊的会话消息内容(仅群聊)
|
||||
|
||||
--group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
|
||||
--group 指定群聊 openConversationId(**本命令仅支持群聊**;拉取单聊/私聊消息请改用 `chat message list-direct`)。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
|
||||
```
|
||||
Usage:
|
||||
dws chat message list [flags]
|
||||
Example:
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
|
||||
dws chat message list --user <userId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --forward=false
|
||||
# 拉单聊改用 list-direct: dws chat message list-direct --user <userId> --time "2025-03-01 00:00:00"
|
||||
Flags:
|
||||
--forward true=拉给定时间之后的消息,false=拉给定时间之前的消息 (default true)
|
||||
--group string 群聊 openconversation_id(群聊时必填)
|
||||
--group string 群聊 openconversation_id(必填,**仅支持群聊**;查单聊用 chat message list-direct --user <userId>)
|
||||
--limit int 返回数量,不传则不限制
|
||||
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
|
||||
--user string 单聊用户 userId(单聊时与 --open-dingtalk-id 二选一)
|
||||
--open-dingtalk-id string 单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景)
|
||||
|
||||
注意:
|
||||
- --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
|
||||
- --user 和 --open-dingtalk-id 都是发起单聊消息拉取,区别在于用不同格式的用户标识:
|
||||
- --user 传 userId(企业内部应用常用)
|
||||
- --open-dingtalk-id 传 openDingTalkId(三方应用或跨组织场景常用,无法获取 userId 时使用)
|
||||
- 本命令**仅支持群聊**,必须指定 --group;拉取单聊(私聊)消息请改用 `chat message list-direct`(旧版的 `list --user` / `list --open-dingtalk-id` 已不再支持)
|
||||
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
|
||||
- 翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
|
||||
- 话题圈消息拉取流程:如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息。要获取完整的话题内容,需要两步操作:(1) 先通过 dws chat message list 拉取话题主消息(即话题帖子本身);(2) 再调用 dws chat message list-topic-replies --group <openConversationId> --topic-id <openConvThreadId> 分页拉取该话题下的所有回复消息。只有话题主消息 + 回复列表合在一起,才是一条话题的完整内容。
|
||||
```
|
||||
|
||||
#### 拉取单聊消息内容 — 按对方 userId 拉取与某同事的单聊(私聊)历史消息
|
||||
|
||||
按对方 userId(或 openDingTalkId)拉取与该同事的单聊会话消息,**专用于私聊**;查群聊请用 `chat message list --group`。同组织内同事用 --user,非同组织好友用 --open-dingtalk-id,二者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
|
||||
```
|
||||
Usage:
|
||||
dws chat message list-direct [flags]
|
||||
Example:
|
||||
dws chat message list-direct --user <对方userId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list-direct --user <对方userId> --time "2025-03-01 00:00:00" --forward=false
|
||||
dws chat message list-direct --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 20
|
||||
# 查询对方 userId: dws contact user search --keyword "姓名" 或 dws aisearch person --keyword "姓名" --dimension name
|
||||
Flags:
|
||||
--forward true=从老往新(给定时间之后),false=从新往老(给定时间之前) (default true)
|
||||
--user string 对方 userId(同组织内同事,与 --open-dingtalk-id 二选一)
|
||||
--open-dingtalk-id string 对方 openDingTalkId(非同组织普通好友场景,与 --user 二选一)
|
||||
--limit int 每页返回数量(默认 50)
|
||||
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
|
||||
|
||||
注意:
|
||||
- --user 与 --open-dingtalk-id 二选一,必须且只能指定其一;同组织同事优先用 --user
|
||||
- --time 必填;翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
|
||||
- 本命令是 `chat message list` 拆分出的单聊专用命令;查群聊消息请用 `chat message list --group`
|
||||
```
|
||||
|
||||
#### 以当前用户身份发送消息 — --group 群聊 / --user 或 --open-dingtalk-id 单聊
|
||||
|
||||
**重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。**
|
||||
@@ -592,7 +611,7 @@ Flags:
|
||||
|
||||
注意:
|
||||
- 四个参数每次请求都会传递给服务端,cursor 首页传 "0"
|
||||
- 与 chat message list 的区别:list 拉取指定单个会话(群聊或单聊)的消息,list-all 拉取当前用户所有会话的消息
|
||||
- 与 chat message list 的区别:list 拉取指定群聊会话的消息(单聊用 list-direct),list-all 拉取当前用户所有会话的消息
|
||||
- 翻页:hasMore=true 时,用响应中的 nextCursor 值作为下次 --cursor 参数继续翻页
|
||||
- 时间格式统一为 yyyy-MM-dd HH:mm:ss
|
||||
```
|
||||
@@ -1147,7 +1166,7 @@ Flags:
|
||||
|
||||
用户说"我特别关注的人最近发了什么消息/关注的人最近聊了啥/星标联系人最近的动态" → `chat message list-focused`(零参数一行命令)
|
||||
用户说"某人发给我的消息/指定发送者的消息/某人最近的消息" → `chat message list-by-sender --sender-user-id <userId>` 或 `--sender-open-dingtalk-id <openDingTalkId>`(跨单聊+群聊)
|
||||
用户说"和某人的单聊聊天记录/拉某人单聊历史" → `chat message list --user <userId>` 或 `--open-dingtalk-id <openDingTalkId>`
|
||||
用户说"和某人的单聊聊天记录/拉某人单聊历史" → `chat message list-direct --user <userId>` 或 `--open-dingtalk-id <openDingTalkId>`
|
||||
用户说"某个群的聊天记录" → `chat message list --group <openConversationId>`
|
||||
用户说"我最近所有消息/我今天的消息" → `chat message list-all --start <ISO> --end <ISO>`
|
||||
用户说"@我的消息/提及我的" → `chat message list-mentions --start <ISO> --end <ISO>`
|
||||
@@ -1167,7 +1186,7 @@ Flags:
|
||||
用户说"改群名" → `chat group rename`
|
||||
用户说"聊天记录/会话消息/拉取会话" → `chat message list`
|
||||
用户说"某人发给我的消息/指定发送者/某人的消息" → `chat message list-by-sender`(用户未明确说"单聊"时优先使用,跨单聊/群聊)
|
||||
用户说"拉取和某人的单聊记录/单聊消息" → `chat message list --user`(用户明确说"单聊"时使用)
|
||||
用户说"拉取和某人的单聊记录/单聊消息" → `chat message list-direct --user`(单聊专用;用户明确说"单聊"时使用)
|
||||
用户说"@我的消息/at我的/提及我的" → `chat message list-mentions`
|
||||
用户说"未读消息会话/未读会话列表/我的未读会话" → `chat message list-unread-conversations`
|
||||
用户说"发群消息(以个人身份)" → `chat message send --group`
|
||||
@@ -1222,8 +1241,8 @@ Flags:
|
||||
|
||||
关键区分:
|
||||
- `chat search` — 搜**群/会话名**返回 `openConversationId`,**不**搜消息内容;要搜消息内容请用 `chat message search-advanced`(首选)/ `chat message search` / `list-by-sender` / `list-all`,**勿混淆**
|
||||
- `chat message list` — 拉取指定会话的消息(需指定 --group 或 --user),按时间点 + 方向翻页
|
||||
- `chat message list --user` — list 的单聊模式,拉取与指定用户的单聊记录(用户明确说"单聊""私聊"时使用)
|
||||
- `chat message list` — 拉取指定**群聊**的消息(需指定 --group,**仅群聊**),按时间点 + 方向翻页
|
||||
- `chat message list-direct` — 单聊专用,拉取与指定用户的单聊(私聊)记录(--user / --open-dingtalk-id;用户明确说"单聊""私聊"时使用)
|
||||
- `chat message list-by-sender` — 搜索指定发送者发给我的消息,跨所有会话(单聊+群聊均包含,用户只说"某人发的消息"时优先使用)
|
||||
- `chat message list-mentions` — 拉取 @我 的消息(跨单聊/群聊,可选指定群)
|
||||
- `chat message list-unread-conversations` — 拉取当前用户存在未读消息的会话列表(可选 `--count`)
|
||||
@@ -1448,8 +1467,8 @@ Flags:
|
||||
| `chat search` | `openConversationId` | message send/list、group members 等的 --group |
|
||||
| `chat group create` | `openConversationId` | 同上 |
|
||||
| `chat message list-all` | `nextCursor` | 下次 list-all 的 --cursor |
|
||||
| `aisearch person` | `userId` | message send 的 --user、send-by-bot 的 --users、send-by-bot 的 --at-user-ids、list-by-sender 的 --sender-user-id |
|
||||
| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、send-by-bot 的 --open-dingtalk-ids、send-by-bot 的 --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id、message list 的 --open-dingtalk-id |
|
||||
| `aisearch person` | `userId` | message send 的 --user、list-direct 的 --user、send-by-bot 的 --users、send-by-bot 的 --at-user-ids、list-by-sender 的 --sender-user-id |
|
||||
| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、list-direct 的 --open-dingtalk-id、send-by-bot 的 --open-dingtalk-ids、send-by-bot 的 --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id |
|
||||
| `chat bot search` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code(仅我创建的机器人,无 openDingTalkId) |
|
||||
| `chat bot find` | `openDingTalkId` | 给机器人发单聊消息(全部可用机器人,额外返回 openDingTalkId) |
|
||||
| `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys |
|
||||
@@ -1484,7 +1503,7 @@ Flags:
|
||||
- `--group` 为群聊会话 ID (openconversation_id),可从群搜索或群聊信息中获取
|
||||
- `chat message send` 的 text 是位置参数(恰好 1 个),非 flag;群聊用 `--group`,单聊用 `--user`(userId)或 `--open-dingtalk-id`(openDingTalkId),三者互斥;纯文本/Markdown 单聊传 `--user` 时直接走 userId 发送能力;`--at-all`、`--at-open-dingtalk-ids` 仅在 `--group` 群聊时生效;富媒体消息通过 `--msg-type` 指定类型(image/file),必须显式指定;发送文件/媒体消息时,必须先根据文件扩展名判断 msgType:图片→image,其他所有→file,不可跳过此判断
|
||||
- `chat message list-all` 的四个参数(--start、--end、--limit、--cursor)每次请求都必须传递;翻页时用响应中的 nextCursor 值作为下次 --cursor
|
||||
- `chat message list` 的 `--group`、`--user`、`--open-dingtalk-id` 三者互斥,必须且只能指定其一
|
||||
- `chat message list` **仅支持群聊**,必须指定 `--group`;拉取单聊用 `chat message list-direct`(`--user` / `--open-dingtalk-id` 二选一)
|
||||
- `chat message list-by-sender` 不需要指定单聊/群聊,返回结果自带会话类型标识;`--sender-user-id`(userId)与 `--sender-open-dingtalk-id`(openDingTalkId)二选一;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`/`--cursor`
|
||||
- `chat message list-mentions` 可选 `--group` 指定群聊,不传则查全部;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`/`--cursor`
|
||||
- `chat message list-unread-conversations` 获取当前用户未读会话列表,可选 `--count` 指定返回条数
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
创建 AI 应用并自动轮询等待完成
|
||||
|
||||
用法:
|
||||
python aiapp_create_and_poll.py \
|
||||
--prompt "创建一个仓库管理应用"
|
||||
|
||||
python aiapp_create_and_poll.py \
|
||||
--prompt "生成客户管理 CRM" \
|
||||
--skills skill1,skill2 \
|
||||
--interval 30 \
|
||||
--timeout 600
|
||||
|
||||
python aiapp_create_and_poll.py --dry-run --prompt "test"
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import argparse
|
||||
import time
|
||||
from typing import List, Any, Optional
|
||||
|
||||
|
||||
def run_dws(
|
||||
args: List[str], dry_run: bool = False,
|
||||
) -> Optional[Any]:
|
||||
cmd = ['dws'] + args
|
||||
if dry_run:
|
||||
print(f"[dry-run] {' '.join(cmd)}")
|
||||
return {'dry_run': True}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=120
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f" ✗ 错误:{result.stderr.strip()}")
|
||||
return None
|
||||
return json.loads(result.stdout)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError,
|
||||
FileNotFoundError) as e:
|
||||
print(f" ✗ 错误:{e}")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='创建 AI 应用并轮询等待完成'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--prompt', required=True, help='应用描述'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--skills', default='', help='技能 ID 列表'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--interval', type=int, default=30,
|
||||
help='轮询间隔秒 (默认 30)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--timeout', type=int, default=600,
|
||||
help='最大等待秒 (默认 600)',
|
||||
)
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f'🚀 创建 AI 应用...')
|
||||
print(f' Prompt: {args.prompt}')
|
||||
cmd_args = [
|
||||
'aiapp', 'create',
|
||||
'--prompt', args.prompt,
|
||||
'--format', 'json',
|
||||
]
|
||||
if args.skills:
|
||||
cmd_args.extend(['--skills', args.skills])
|
||||
|
||||
create_data = run_dws(cmd_args, dry_run=args.dry_run)
|
||||
if args.dry_run:
|
||||
run_dws([
|
||||
'aiapp', 'query',
|
||||
'--task-id', '<TASK_ID>',
|
||||
'--format', 'json',
|
||||
], dry_run=True)
|
||||
return
|
||||
|
||||
if not create_data:
|
||||
sys.exit(1)
|
||||
|
||||
task_id = create_data.get('taskId') or create_data.get('id', '')
|
||||
thread_id = create_data.get('threadId', '')
|
||||
print(f" ✓ 任务已创建")
|
||||
print(f" taskId: {task_id}")
|
||||
print(f" threadId: {thread_id}")
|
||||
|
||||
print(f'\n⏳ 轮询等待 (间隔 {args.interval}s, '
|
||||
f'超时 {args.timeout}s)...')
|
||||
elapsed = 0
|
||||
while elapsed < args.timeout:
|
||||
time.sleep(args.interval)
|
||||
elapsed += args.interval
|
||||
|
||||
query_data = run_dws([
|
||||
'aiapp', 'query',
|
||||
'--task-id', task_id,
|
||||
'--format', 'json',
|
||||
])
|
||||
if not query_data:
|
||||
print(f" [{elapsed}s] ⚠ 查询失败,继续等待...")
|
||||
continue
|
||||
|
||||
status = (query_data.get('status')
|
||||
or query_data.get('state', 'unknown'))
|
||||
progress = query_data.get('progress', {})
|
||||
step = ''
|
||||
if isinstance(progress, dict):
|
||||
step = progress.get('currentStep', '')
|
||||
|
||||
if status == 'succeeded':
|
||||
print(f" [{elapsed}s] ✅ 应用创建成功!")
|
||||
if thread_id:
|
||||
print(f" threadId: {thread_id}")
|
||||
return
|
||||
elif status == 'failed':
|
||||
print(f" [{elapsed}s] ❌ 创建失败")
|
||||
sys.exit(1)
|
||||
else:
|
||||
info = f" [{elapsed}s] ⏳ {status}"
|
||||
if step:
|
||||
info += f" ({step})"
|
||||
print(info)
|
||||
|
||||
print(f"\n⏰ 超时 ({args.timeout}s),任务可能仍在运行")
|
||||
print(f" 可手动查询: dws aiapp query --task-id {task_id}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
工作流:
|
||||
1. 通过 --name 搜索通讯录,获取 userId(或直接传 --user)
|
||||
2. 调用 chat message list --user <userId> 拉取单聊消息
|
||||
2. 调用 chat message list-direct --user <userId> 拉取单聊消息
|
||||
3. 输出到终端或导出为 JSON 文件
|
||||
"""
|
||||
|
||||
@@ -116,7 +116,7 @@ def main():
|
||||
|
||||
while page < max_pages and remaining > 0:
|
||||
cmd_args = [
|
||||
'chat', 'message', 'list',
|
||||
'chat', 'message', 'list-direct',
|
||||
'--user', user_id or '<USER_ID>',
|
||||
'--time', current_time,
|
||||
'--format', 'json',
|
||||
@@ -139,8 +139,12 @@ def main():
|
||||
page_msgs = data
|
||||
has_more = False
|
||||
else:
|
||||
page_msgs = data.get('messages', data.get('result', []))
|
||||
has_more = data.get('hasMore', False)
|
||||
# list-direct 返回 {result: {hasMore, messages: [...]}},先解包 result
|
||||
result_obj = data.get('result', data)
|
||||
if not isinstance(result_obj, dict):
|
||||
result_obj = data
|
||||
page_msgs = result_obj.get('messages', result_obj.get('records', []))
|
||||
has_more = result_obj.get('hasMore', data.get('hasMore', False))
|
||||
|
||||
if not page_msgs:
|
||||
break
|
||||
@@ -153,7 +157,8 @@ def main():
|
||||
break
|
||||
|
||||
last_msg = page_msgs[-1]
|
||||
boundary_time = last_msg.get('createAt') or last_msg.get('time', '')
|
||||
boundary_time = (last_msg.get('createTime') or last_msg.get('createAt')
|
||||
or last_msg.get('time', ''))
|
||||
if not boundary_time or boundary_time == current_time:
|
||||
break
|
||||
current_time = boundary_time
|
||||
@@ -183,9 +188,11 @@ def main():
|
||||
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
|
||||
else:
|
||||
for m in all_messages:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
sender = m.get('senderNick') or m.get('sender', '未知')
|
||||
text = m.get('text') or m.get('content', '')
|
||||
time_str = m.get('createAt') or m.get('time', '')
|
||||
time_str = m.get('createTime') or m.get('createAt') or m.get('time', '')
|
||||
print(f" [{time_str}] {sender}: {text[:80]}")
|
||||
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
name: dingtalk-aiapp
|
||||
description: 钉钉 AI 应用生成。Use when 用户说 创建应用/生成系统/做工具/管理后台/工作台应用/表单系统/业务原型/页面/平台。强制触发:用户提到「应用 / 系统 / 平台 / 工具 / 后台 / 页面 / 原型」时优先匹配此 skill。Distinct from dingtalk-workbench(工作台应用列表)、dingtalk-wiki(知识库)。命令前缀:dws aiapp。
|
||||
cli_version: ">=0.2.14"
|
||||
metadata:
|
||||
category: product
|
||||
stability: experimental
|
||||
requires:
|
||||
bins:
|
||||
- dws
|
||||
---
|
||||
|
||||
# 钉钉 AI 应用 Skill
|
||||
|
||||
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
|
||||
|
||||
> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH.
|
||||
|
||||
<!-- SAFETY_PREAMBLE_INJECT -->
|
||||
|
||||
> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
|
||||
|
||||
|
||||
> 命令参考:[aiapp.md](references/aiapp.md)。
|
||||
|
||||
## 意图表
|
||||
|
||||
| 用户说 | 命令 |
|
||||
|--------|------|
|
||||
| "创建一个 XX 应用 / 系统 / 工具" | `python scripts/aiapp_create_and_poll.py --prompt "<用户原始需求>"`(自动轮询进度) |
|
||||
| "查 AI 应用 / 修改已有应用" | 见 [aiapp.md](references/aiapp.md) |
|
||||
|
||||
## 跨产品协作
|
||||
|
||||
- 已存在的工作台应用列表 → 切到 `dingtalk-workbench`
|
||||
@@ -1,417 +0,0 @@
|
||||
# AI应用 (aiapp) 命令参考
|
||||
|
||||
## 强制触发规则
|
||||
|
||||
以下意图默认必须优先使用 `aiapp`,即使用户没有说“AI应用”:
|
||||
|
||||
- 创建一个新应用
|
||||
- 创建一个新系统
|
||||
- 生成一个业务工具
|
||||
- 按需求生成一个管理后台、工作台应用、表单系统、业务原型
|
||||
- 修改一个已经生成过的应用
|
||||
|
||||
典型触发词包括但不限于:
|
||||
|
||||
- 应用、系统、平台、工具、后台、页面、原型、工作台应用
|
||||
- CRM、OA、库存、仓库、审批、销售、报销、进销存、客户管理、数据录入、查询工具
|
||||
- 计算器、待办工具、表单应用、信息收集应用、报表应用
|
||||
- “帮我做一个…”
|
||||
- “帮我创建一个…”
|
||||
- “按这个需求生成一个…”
|
||||
- “把这个需求做成应用”
|
||||
|
||||
典型例子:
|
||||
|
||||
- “帮我创建个简易的计算器应用”
|
||||
- “做一个仓库管理系统”
|
||||
- “生成一个客户管理工具”
|
||||
- “按这个 PRD 创建一个钉钉应用”
|
||||
- “修改刚才那个应用,把首页改得更简洁”
|
||||
|
||||
不要误路由到其他产品:
|
||||
|
||||
- 不要因为用户提到“表格/记录/字段”,就优先走 `aitable`,如果用户的真实目标是“生成一个完整应用”
|
||||
- 不要因为用户提到“文档/需求”,就优先走 `doc`,如果用户的真实目标是“把需求做成应用”
|
||||
- 不要因为用户提到“任务/项目”,就优先走 `tb`,如果用户的真实目标是“生成一个管理系统”
|
||||
|
||||
只有在以下场景不要走 `aiapp`:
|
||||
|
||||
- 用户明确要操作一个现成的多维表、文档、项目、日历、机器人消息
|
||||
- 用户不是要“生成应用”,而是要“修改已有产品对象”
|
||||
|
||||
## 命令总览
|
||||
|
||||
### 创建 AI 应用
|
||||
```
|
||||
Usage:
|
||||
dws aiapp create [flags]
|
||||
Example:
|
||||
dws aiapp create --prompt "创建一个天气查询应用"
|
||||
dws aiapp create --prompt "翻译应用" --skills s1,s2
|
||||
dws aiapp create --prompt "根据附件里的 Excel 创建一个仓库管理应用" --attachments '[{"name":"warehouse.xlsx","type":"excel","url":"https://tmp/warehouse.xlsx"}]'
|
||||
Flags:
|
||||
--prompt string 创建 AI 应用的 prompt (必填)
|
||||
--skills string 技能 ID 列表,逗号分隔
|
||||
--attachments string 附件对象数组的 JSON 字符串,用于把 Excel/图片等输入材料一起传入
|
||||
```
|
||||
|
||||
### 查询 AI 应用
|
||||
```
|
||||
Usage:
|
||||
dws aiapp query [flags]
|
||||
Example:
|
||||
dws aiapp query --task-id <taskId>
|
||||
Flags:
|
||||
--task-id string AI 应用任务 ID (必填)
|
||||
```
|
||||
|
||||
### 修改 AI 应用
|
||||
```
|
||||
Usage:
|
||||
dws aiapp modify [flags]
|
||||
Example:
|
||||
dws aiapp modify --prompt "改为翻译应用" --thread-id <threadId>
|
||||
dws aiapp modify --prompt "新描述" --thread-id <threadId> --skills s1,s2
|
||||
Flags:
|
||||
--prompt string 新的 prompt (必填)
|
||||
--skills string 技能 ID 列表,逗号分隔
|
||||
--thread-id string threadId (必填)
|
||||
```
|
||||
|
||||
## 三个动作的关系
|
||||
|
||||
`aiapp` 的三个动作不是彼此独立的,它们描述的是同一个应用任务的生命周期:
|
||||
|
||||
1. `create` 用来发起一次新的应用生成任务,返回 `taskId` 和 `threadId`
|
||||
2. `query` 用来跟踪这次任务是否完成,以及读取生成结果
|
||||
3. `modify` 用来基于同一个 `threadId` 继续和已生成的应用对话,本质上是对已有应用做增量修改
|
||||
|
||||
可以把它理解为:
|
||||
|
||||
- `create` = 新建一个应用会话
|
||||
- `query` = 查看这个会话对应任务的执行状态和结果
|
||||
- `modify` = 在这个会话里继续追加新的需求,让同一个应用继续演化
|
||||
|
||||
关键标识符的关系:
|
||||
|
||||
- `taskId` 表示某一次具体的执行任务,主要给 `query` 使用
|
||||
- `threadId` 表示这个应用的持续会话,主要给 `modify` 使用
|
||||
- 一次 `create` 会产生一个新的 `taskId`,同时也会产出或确认一个 `threadId`
|
||||
- 后续每次 `modify` 都是在同一个 `threadId` 上发起新的任务,因此也会产生新的 `taskId`
|
||||
|
||||
因此推荐把它们看成一条闭环链路,而不是三个平级命令:
|
||||
|
||||
`create -> query -> modify -> query -> modify ...`
|
||||
|
||||
## `attachments` 的含义
|
||||
|
||||
`attachments` 是 `aiapp` 的输入材料参数,用来把文件和当前任务一起提交给 `aiapp`。
|
||||
|
||||
它适合这些场景:
|
||||
|
||||
- 根据 Excel 创建应用
|
||||
- 根据需求文档、表格、截图继续完善应用
|
||||
- 在创建应用时补充新的结构化材料
|
||||
|
||||
可以把它理解为:
|
||||
|
||||
- `prompt` 描述“希望系统做什么”
|
||||
- `attachments` 提供“系统可直接参考的文件材料”
|
||||
|
||||
### 正确心智
|
||||
|
||||
- 当前 CLI 中,`attachments` 是 `create` 的补充输入,不是独立动作
|
||||
- 即使提供了附件,主动作仍然是 `aiapp create`
|
||||
- 当用户说“按这个 Excel 建一个应用”时,优先走 `aiapp`,并把 Excel 作为 `attachments` 传入
|
||||
|
||||
### 数据结构
|
||||
|
||||
`attachments` 不是单个字符串,而是对象数组。每个对象当前按以下结构传递:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "warehouse.xlsx",
|
||||
"type": "excel",
|
||||
"url": "https://tmp/warehouse.xlsx",
|
||||
"size": 102400
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
每个附件对象当前可用字段:
|
||||
|
||||
- `name`:文件名
|
||||
- `type`:文件类型或 MIME 类型
|
||||
- `url`:可直接访问的临时下载地址
|
||||
- `size`:文件大小,可选
|
||||
|
||||
如果要传多个附件,就在数组中放多个对象。
|
||||
|
||||
### Excel 场景
|
||||
|
||||
如果用户上传了 Excel,通常应该:
|
||||
|
||||
- 在 `prompt` 里明确写“根据附件中的 Excel 创建应用”
|
||||
- 同时把 Excel 通过 `attachments` 传给 `aiapp`
|
||||
|
||||
这类需求都应优先视为 `aiapp`:
|
||||
|
||||
- “根据这个 Excel 建一个库存管理应用”
|
||||
- “把附件里的表格做成一个 CRM 应用”
|
||||
- “按这个 Excel 生成一个新应用”
|
||||
|
||||
### 使用规则
|
||||
|
||||
- `create` 可以带图片、Excel 等附件
|
||||
- 当前 `modify` 不支持 `--attachments` 参数;需要结合新材料修改应用时,把材料内容概括进 `prompt`
|
||||
- `query` 不使用 `attachments`
|
||||
- 附件应按对象数组传递,而不是传单个 ID 或随意文本
|
||||
- 如果用户提供本地文件并要创建应用 → 先上传文件获取 URL,再执行 `aiapp create`
|
||||
- 每个附件对象通常至少应包含可访问的临时下载地址 `url`
|
||||
- 如果有附件但没有特别完整的 prompt,也仍然建议补一句明确意图,避免模型误判
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 根据 Excel 创建应用
|
||||
dws aiapp create --prompt "根据附件里的 Excel 创建一个仓库管理应用" \
|
||||
--attachments '[{"name":"warehouse.xlsx","type":"excel","url":"https://tmp/warehouse.xlsx"}]' \
|
||||
--format json
|
||||
|
||||
# 创建应用时结合图片材料
|
||||
dws aiapp create --prompt "根据附件图片创建一个首页风格参考应用" \
|
||||
--attachments '[{"name":"homepage.png","type":"image/png","url":"https://tmp/homepage.png"}]' \
|
||||
--format json
|
||||
```
|
||||
|
||||
## `--skills` 的含义
|
||||
|
||||
`--skills` 是 `aiapp` 的附加能力参数,不是一个独立动作。
|
||||
|
||||
它的作用不是“先执行这些技能,再单独执行 `aiapp`”,而是:
|
||||
|
||||
- 在 `create` 时,把这些官方技能作为本次应用生成任务的附加约束或附加能力一起传入
|
||||
- 在 `modify` 时,把这些官方技能作为本次修改任务的附加约束或附加能力一起传入
|
||||
|
||||
可以把它理解为:
|
||||
|
||||
- `prompt` 描述“你想做什么应用”
|
||||
- `--skills` 描述“这次生成或修改时,要额外挂载哪些官方能力或规范”
|
||||
|
||||
因此 `--skills` 必须依附于 `create` 或 `modify` 使用,本身不能单独完成创建应用。
|
||||
|
||||
### 正确心智
|
||||
|
||||
- `aiapp` 是主动作
|
||||
- `--skills` 是主动作的附加参数
|
||||
- 用户是否挂载官方技能,会影响应用生成或修改效果
|
||||
- 即使用户提到了某个官方技能,最终仍然应该优先调用 `aiapp create` 或 `aiapp modify`
|
||||
|
||||
### 常见误区
|
||||
|
||||
不要把以下场景理解错:
|
||||
|
||||
- 用户说“创建一个应用,并遵循设计规范”
|
||||
- 正确做法:调用 `aiapp create ... --skills <design-skill-id>`
|
||||
- 不正确做法:把“设计规范技能”当成一个和 `aiapp` 平级、单独执行的动作
|
||||
|
||||
- 用户说“继续修改刚才那个应用,并带上设计规范”
|
||||
- 正确做法:调用 `aiapp modify ... --thread-id <threadId> --skills <design-skill-id>`
|
||||
- 不正确做法:先执行设计规范技能,再单独调用 `modify`
|
||||
|
||||
- 用户只是在描述“希望页面更漂亮、更标准”
|
||||
- 如果已知对应的官方技能 ID,可以通过 `--skills` 传入
|
||||
- 如果没有明确的技能 ID,不要编造,优先把需求写进 `prompt`
|
||||
|
||||
### 什么时候传 `--skills`
|
||||
|
||||
适合传 `--skills` 的情况:
|
||||
|
||||
- 用户明确要求遵循某个已知的官方能力、官方规范、官方模板
|
||||
- 当前上下文里已经拿到了可用的官方技能 ID/Code
|
||||
- 需要在创建或修改时显式挂载这些技能
|
||||
|
||||
不适合传 `--skills` 的情况:
|
||||
|
||||
- 没有明确的技能 ID
|
||||
- 只是模糊地希望“更专业”“更好看”,但没有对应官方技能可挂
|
||||
- 技能本身不是 `aiapp` 官方子技能,而是其他产品的独立操作能力
|
||||
|
||||
### 使用规则
|
||||
|
||||
- `create` 和 `modify` 都可以带 `--skills`
|
||||
- `query` 不使用 `--skills`
|
||||
- `--skills` 里的值必须是技能 ID,多个值用逗号分隔
|
||||
- 不要猜测、编造技能 ID,必须来自已知上下文或系统返回
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 创建应用,并挂载一个钉钉设计规范技能
|
||||
dws aiapp create --prompt "创建一个仓库管理应用" \
|
||||
--skills dingtalk_design_spec --format json
|
||||
|
||||
# 修改已有应用,并继续带上官方技能
|
||||
dws aiapp modify --prompt "增加个库存大盘图表页" \
|
||||
--thread-id <threadId> \
|
||||
--skills dingtalk_design_spec --format json
|
||||
```
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说以下任何一种,都走 `create`:
|
||||
|
||||
- “做个应用 / 创建应用 / 生成应用 / 搭个应用”
|
||||
- “做个系统 / 平台 / 后台 / 工具 / 页面”
|
||||
- “帮我做个计算器 / 仓库管理 / CRM / OA / 审批 / 报表 / 信息收集应用”
|
||||
- “按这段需求生成一个钉钉应用”
|
||||
|
||||
用户说以下任何一种,都走 `query`:
|
||||
|
||||
- “查看应用状态”
|
||||
- “这个应用创建好了没”
|
||||
- “查一下 taskId 对应的进度”
|
||||
- “继续轮询应用创建进展”
|
||||
|
||||
用户说以下任何一种,都走 `modify`:
|
||||
|
||||
- “修改应用”
|
||||
- “更新刚才那个应用”
|
||||
- “基于这个 thread/task 继续改”
|
||||
- “把首页改一下 / 把功能补上 / 换个风格”
|
||||
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
# 1. 创建 AI 应用 — 提取 taskId 和 threadId
|
||||
dws aiapp create --prompt "创建一个天气查询应用" --format json
|
||||
|
||||
# 2. 查询应用状态
|
||||
dws aiapp query --task-id <taskId> --format json
|
||||
|
||||
# 3. 修改应用
|
||||
dws aiapp modify --prompt "新描述" --thread-id <threadId> \
|
||||
--skills skill1,skill2 --format json
|
||||
```
|
||||
|
||||
补充理解:
|
||||
|
||||
- 如果用户还没有现成应用,先走 `create`
|
||||
- 如果用户刚发起了创建,通常先走 `query` 看是否完成
|
||||
- 如果用户说“继续改刚才那个应用”,优先找上一次返回里的 `threadId`,走 `modify`
|
||||
- `modify` 完成后,仍然可以继续用新的 `taskId` 走 `query`
|
||||
- 不要把 `modify` 理解成“覆盖旧应用”,它更像是在同一个应用会话中追加新需求
|
||||
- `attachments` 不改变 `create/query/modify` 的主流程,它只是给 `create` 增加文件输入
|
||||
- 如果附件是 Excel,优先理解为 `create` 场景;`modify` 当前不要使用附件参数
|
||||
- `--skills` 不改变 `create/query/modify` 的主流程,它只是给 `create` 或 `modify` 增加官方附加能力
|
||||
|
||||
推荐决策顺序:
|
||||
|
||||
1. 先判断用户是不是要“生成一个新的应用/系统”
|
||||
2. 如果是,直接用 `aiapp create`
|
||||
3. 如果用户是在已有应用基础上继续改,用 `aiapp modify`
|
||||
4. 如果只是查进度或结果,用 `aiapp query`
|
||||
|
||||
## 查询与轮询预期
|
||||
|
||||
`aiapp` 的创建和修改通常不是秒级任务,而是分钟级任务。
|
||||
|
||||
- 简单应用通常需要几分钟
|
||||
- 复杂应用可能持续十几分钟到几十分钟
|
||||
|
||||
因此在使用 `query` 时,不要假设“连续几次没有完成”就代表任务卡死。
|
||||
|
||||
### 正确的轮询策略
|
||||
|
||||
- 不要每 5-10 秒高频轮询
|
||||
- 建议以 30 秒为默认轮询间隔
|
||||
- 如果任务已经明确进入长流程,可进一步放宽到 60 秒
|
||||
- 除非用户明确要求实时刷新,否则不要自行创建过于频繁的定时查询
|
||||
|
||||
### 代理行为约束
|
||||
|
||||
当 agent 已经帮用户发起了 `aiapp create` 或 `aiapp modify` 后,不要只查询 1-2 次就把后续进度甩给用户自己处理。
|
||||
|
||||
- 只要任务仍处于 `queued` 或 `running`,且用户没有明确要求停止,agent 应继续主动跟踪进度
|
||||
- 如果当前会话无法持续等待,可以创建定时任务继续查询,但定时任务必须是“以拿到最终结果为目标”的,而不是无条件永久轮询
|
||||
- 如果返回了 `threadViewUrl`,可以告诉用户“你也可以点这个链接查看实时进度”,但这不能替代 agent 自己继续追踪任务
|
||||
|
||||
### 定时任务规则
|
||||
|
||||
如果 agent 选择创建定时任务来继续查询 `aiapp` 状态,必须同时设置结束条件:
|
||||
|
||||
- 当 `status = succeeded` 且已拿到 `appPreviewUrl` 时,立即停止后续查询,并主动删除或关闭该定时任务
|
||||
- 当 `status = failed` 时,立即停止后续查询,并主动删除或关闭该定时任务
|
||||
- 不要在已经拿到目标结果后继续保留定时任务
|
||||
- 不要创建“只会查询、不会自终止”的定时任务
|
||||
|
||||
换句话说,定时任务应当是“查询直到成功或失败,然后自动结束”的一次性追踪任务,而不是长期后台轮询
|
||||
|
||||
### 如何理解 `query` 返回
|
||||
|
||||
`query` 的返回里,除了总状态外,还可能包含进度信息,例如:
|
||||
|
||||
- 当前正在执行哪个步骤
|
||||
- 已完成多少步骤
|
||||
- 最近一次活动时间
|
||||
- 当前步骤列表
|
||||
- 当前 thread 的访问链接
|
||||
|
||||
因此判断任务是否还在推进时,不要只看 `status` 是否还是 `running`,还要结合以下字段一起看:
|
||||
|
||||
- `updatedAt` / `lastActivityAt`
|
||||
- `progress.summary`
|
||||
- `progress.currentStep`
|
||||
- `progress.steps`
|
||||
- `threadViewUrl`
|
||||
|
||||
只要这些字段仍在变化,通常就说明任务仍在正常推进。
|
||||
|
||||
### `threadViewUrl` 与 `appPreviewUrl` 的区别
|
||||
|
||||
`query` 或 `create` 的返回里,可能会有两个不同的链接:
|
||||
|
||||
- `threadViewUrl`:当前 thread 的访问链接。用于打开 `aiapp` 站点里的会话/生成进度页面,适合在应用还没生成完成时查看实时状态
|
||||
- `appPreviewUrl`:最终生成出的应用访问地址。只有应用创建成功后才会出现,适合真正打开应用去使用
|
||||
|
||||
不要把这两个链接混用:
|
||||
|
||||
- 任务还在 `queued/running` 时,优先使用 `threadViewUrl`
|
||||
- 任务 `succeeded` 且结果里已有 `appPreviewUrl` 时,再使用 `appPreviewUrl`
|
||||
|
||||
### 何时停止轮询
|
||||
|
||||
- 当 `status = succeeded` 时,停止轮询,并从结果中提取 `threadId`、应用链接等信息
|
||||
- 当 `status = failed` 时,停止轮询,并向用户报告失败
|
||||
- 当任务长时间保持 `running`,但 `lastActivityAt` 和 `progress` 都没有变化时,再考虑提示可能卡住,而不是过早下结论
|
||||
- 如果此前为查询进度创建了定时任务,在 `succeeded` 或 `failed` 后要主动删除该定时任务,而不是留给用户处理
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `create` | `taskId`, `threadId`, `threadViewUrl` | query 的 --task-id, modify 的 --thread-id,或直接打开进度页 |
|
||||
| `query` | `threadViewUrl`, `appPreviewUrl`, 应用详情 | 查看进度页,或在成功后打开应用预览并决定是否 modify |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `create` 通过自然语言 prompt 描述应用功能,系统自动生成
|
||||
- 对“应用/系统/工具/后台/原型”类需求,优先使用 `aiapp`,不要改用其他产品命令模拟实现
|
||||
- 对“根据附件 / 根据 Excel / 根据上传表格生成应用”这类需求,也优先使用 `aiapp`
|
||||
- `attachments` 需要按对象数组传递,当前结构是 `[{name,type,url,size}]`
|
||||
- `create` 可使用 Excel、图片等附件;`modify` 当前不支持附件参数
|
||||
- `--skills` 传入官方技能 UID 列表,逗号分隔,可增强应用能力
|
||||
- `modify` 需要 `--thread-id` 来标识修改哪个会话线程
|
||||
- 应用未生成完成前,如果需要让用户查看实时状态,优先使用返回里的 `threadViewUrl`
|
||||
- 应用生成成功后,如果需要真正打开应用,再使用 `appPreviewUrl`
|
||||
- 创建后可通过 `query` 轮询 `taskId` 查看创建进度,但轮询间隔默认应为 20-30 秒,不要 10 秒级频繁轮询
|
||||
- agent 不应只查 1-2 次就放弃;如果任务仍在运行,应继续跟踪,或创建带终止条件的定时任务继续跟踪
|
||||
- 如果 agent 创建了用于查询进度的定时任务,在任务成功或失败后必须主动删除或结束该定时任务
|
||||
- 不要直接使用用户本地文件路径作为附件 URL,必须先上传转换为互联网可访问链接
|
||||
|
||||
## 自动化脚本
|
||||
|
||||
| 脚本 | 场景 | 用法 |
|
||||
|------|------|------|
|
||||
| [aiapp_create_and_poll.py](../../scripts/aiapp_create_and_poll.py) | 一键创建 AI 应用并自动轮询进度直到完成 | `python aiapp_create_and_poll.py --prompt "创建一个天气查询应用"` |
|
||||
@@ -1,138 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
创建 AI 应用并自动轮询等待完成
|
||||
|
||||
用法:
|
||||
python aiapp_create_and_poll.py \
|
||||
--prompt "创建一个仓库管理应用"
|
||||
|
||||
python aiapp_create_and_poll.py \
|
||||
--prompt "生成客户管理 CRM" \
|
||||
--skills skill1,skill2 \
|
||||
--interval 30 \
|
||||
--timeout 600
|
||||
|
||||
python aiapp_create_and_poll.py --dry-run --prompt "test"
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import argparse
|
||||
import time
|
||||
from typing import List, Any, Optional
|
||||
|
||||
|
||||
def run_dws(
|
||||
args: List[str], dry_run: bool = False,
|
||||
) -> Optional[Any]:
|
||||
cmd = ['dws'] + args
|
||||
if dry_run:
|
||||
print(f"[dry-run] {' '.join(cmd)}")
|
||||
return {'dry_run': True}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=120
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f" ✗ 错误:{result.stderr.strip()}")
|
||||
return None
|
||||
return json.loads(result.stdout)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError,
|
||||
FileNotFoundError) as e:
|
||||
print(f" ✗ 错误:{e}")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='创建 AI 应用并轮询等待完成'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--prompt', required=True, help='应用描述'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--skills', default='', help='技能 ID 列表'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--interval', type=int, default=30,
|
||||
help='轮询间隔秒 (默认 30)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--timeout', type=int, default=600,
|
||||
help='最大等待秒 (默认 600)',
|
||||
)
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f'🚀 创建 AI 应用...')
|
||||
print(f' Prompt: {args.prompt}')
|
||||
cmd_args = [
|
||||
'aiapp', 'create',
|
||||
'--prompt', args.prompt,
|
||||
'--format', 'json',
|
||||
]
|
||||
if args.skills:
|
||||
cmd_args.extend(['--skills', args.skills])
|
||||
|
||||
create_data = run_dws(cmd_args, dry_run=args.dry_run)
|
||||
if args.dry_run:
|
||||
run_dws([
|
||||
'aiapp', 'query',
|
||||
'--task-id', '<TASK_ID>',
|
||||
'--format', 'json',
|
||||
], dry_run=True)
|
||||
return
|
||||
|
||||
if not create_data:
|
||||
sys.exit(1)
|
||||
|
||||
task_id = create_data.get('taskId') or create_data.get('id', '')
|
||||
thread_id = create_data.get('threadId', '')
|
||||
print(f" ✓ 任务已创建")
|
||||
print(f" taskId: {task_id}")
|
||||
print(f" threadId: {thread_id}")
|
||||
|
||||
print(f'\n⏳ 轮询等待 (间隔 {args.interval}s, '
|
||||
f'超时 {args.timeout}s)...')
|
||||
elapsed = 0
|
||||
while elapsed < args.timeout:
|
||||
time.sleep(args.interval)
|
||||
elapsed += args.interval
|
||||
|
||||
query_data = run_dws([
|
||||
'aiapp', 'query',
|
||||
'--task-id', task_id,
|
||||
'--format', 'json',
|
||||
])
|
||||
if not query_data:
|
||||
print(f" [{elapsed}s] ⚠ 查询失败,继续等待...")
|
||||
continue
|
||||
|
||||
status = (query_data.get('status')
|
||||
or query_data.get('state', 'unknown'))
|
||||
progress = query_data.get('progress', {})
|
||||
step = ''
|
||||
if isinstance(progress, dict):
|
||||
step = progress.get('currentStep', '')
|
||||
|
||||
if status == 'succeeded':
|
||||
print(f" [{elapsed}s] ✅ 应用创建成功!")
|
||||
if thread_id:
|
||||
print(f" threadId: {thread_id}")
|
||||
return
|
||||
elif status == 'failed':
|
||||
print(f" [{elapsed}s] ❌ 创建失败")
|
||||
sys.exit(1)
|
||||
else:
|
||||
info = f" [{elapsed}s] ⏳ {status}"
|
||||
if step:
|
||||
info += f" ({step})"
|
||||
print(info)
|
||||
|
||||
print(f"\n⏰ 超时 ({args.timeout}s),任务可能仍在运行")
|
||||
print(f" 可手动查询: dws aiapp query --task-id {task_id}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -338,35 +338,54 @@ Flags:
|
||||
|
||||
### message (会话消息管理)
|
||||
|
||||
#### 拉取会话消息内容 — 拉取指定群聊或单聊的会话消息内容
|
||||
#### 拉取群聊会话消息内容 — 拉取指定群聊的会话消息内容(仅群聊)
|
||||
|
||||
--group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
|
||||
--group 指定群聊 openConversationId(**本命令仅支持群聊**;拉取单聊/私聊消息请改用 `chat message list-direct`)。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
|
||||
```
|
||||
Usage:
|
||||
dws chat message list [flags]
|
||||
Example:
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
|
||||
dws chat message list --user <userId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --forward=false
|
||||
# 拉单聊改用 list-direct: dws chat message list-direct --user <userId> --time "2025-03-01 00:00:00"
|
||||
Flags:
|
||||
--forward true=拉给定时间之后的消息,false=拉给定时间之前的消息 (default true)
|
||||
--group string 群聊 openconversation_id(群聊时必填)
|
||||
--group string 群聊 openconversation_id(必填,**仅支持群聊**;查单聊用 chat message list-direct --user <userId>)
|
||||
--limit int 返回数量,不传则不限制
|
||||
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
|
||||
--user string 单聊用户 userId(单聊时与 --open-dingtalk-id 二选一)
|
||||
--open-dingtalk-id string 单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景)
|
||||
|
||||
注意:
|
||||
- --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
|
||||
- --user 和 --open-dingtalk-id 都是发起单聊消息拉取,区别在于用不同格式的用户标识:
|
||||
- --user 传 userId(企业内部应用常用)
|
||||
- --open-dingtalk-id 传 openDingTalkId(三方应用或跨组织场景常用,无法获取 userId 时使用)
|
||||
- 本命令**仅支持群聊**,必须指定 --group;拉取单聊(私聊)消息请改用 `chat message list-direct`(旧版的 `list --user` / `list --open-dingtalk-id` 已不再支持)
|
||||
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
|
||||
- 翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
|
||||
- 话题圈消息拉取流程:如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息。要获取完整的话题内容,需要两步操作:(1) 先通过 dws chat message list 拉取话题主消息(即话题帖子本身);(2) 再调用 dws chat message list-topic-replies --group <openConversationId> --topic-id <openConvThreadId> 分页拉取该话题下的所有回复消息。只有话题主消息 + 回复列表合在一起,才是一条话题的完整内容。
|
||||
```
|
||||
|
||||
#### 拉取单聊消息内容 — 按对方 userId 拉取与某同事的单聊(私聊)历史消息
|
||||
|
||||
按对方 userId(或 openDingTalkId)拉取与该同事的单聊会话消息,**专用于私聊**;查群聊请用 `chat message list --group`。同组织内同事用 --user,非同组织好友用 --open-dingtalk-id,二者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
|
||||
```
|
||||
Usage:
|
||||
dws chat message list-direct [flags]
|
||||
Example:
|
||||
dws chat message list-direct --user <对方userId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list-direct --user <对方userId> --time "2025-03-01 00:00:00" --forward=false
|
||||
dws chat message list-direct --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 20
|
||||
# 查询对方 userId: dws contact user search --keyword "姓名" 或 dws aisearch person --keyword "姓名" --dimension name
|
||||
Flags:
|
||||
--forward true=从老往新(给定时间之后),false=从新往老(给定时间之前) (default true)
|
||||
--user string 对方 userId(同组织内同事,与 --open-dingtalk-id 二选一)
|
||||
--open-dingtalk-id string 对方 openDingTalkId(非同组织普通好友场景,与 --user 二选一)
|
||||
--limit int 每页返回数量(默认 50)
|
||||
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
|
||||
|
||||
注意:
|
||||
- --user 与 --open-dingtalk-id 二选一,必须且只能指定其一;同组织同事优先用 --user
|
||||
- --time 必填;翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
|
||||
- 本命令是 `chat message list` 拆分出的单聊专用命令;查群聊消息请用 `chat message list --group`
|
||||
```
|
||||
|
||||
#### 以当前用户身份发送消息 — --group 群聊 / --user 或 --open-dingtalk-id 单聊
|
||||
|
||||
**重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。**
|
||||
@@ -592,7 +611,7 @@ Flags:
|
||||
|
||||
注意:
|
||||
- 四个参数每次请求都会传递给服务端,cursor 首页传 "0"
|
||||
- 与 chat message list 的区别:list 拉取指定单个会话(群聊或单聊)的消息,list-all 拉取当前用户所有会话的消息
|
||||
- 与 chat message list 的区别:list 拉取指定群聊会话的消息(单聊用 list-direct),list-all 拉取当前用户所有会话的消息
|
||||
- 翻页:hasMore=true 时,用响应中的 nextCursor 值作为下次 --cursor 参数继续翻页
|
||||
- 时间格式统一为 yyyy-MM-dd HH:mm:ss
|
||||
```
|
||||
@@ -1147,7 +1166,7 @@ Flags:
|
||||
|
||||
用户说"我特别关注的人最近发了什么消息/关注的人最近聊了啥/星标联系人最近的动态" → `chat message list-focused`(零参数一行命令)
|
||||
用户说"某人发给我的消息/指定发送者的消息/某人最近的消息" → `chat message list-by-sender --sender-user-id <userId>` 或 `--sender-open-dingtalk-id <openDingTalkId>`(跨单聊+群聊)
|
||||
用户说"和某人的单聊聊天记录/拉某人单聊历史" → `chat message list --user <userId>` 或 `--open-dingtalk-id <openDingTalkId>`
|
||||
用户说"和某人的单聊聊天记录/拉某人单聊历史" → `chat message list-direct --user <userId>` 或 `--open-dingtalk-id <openDingTalkId>`
|
||||
用户说"某个群的聊天记录" → `chat message list --group <openConversationId>`
|
||||
用户说"我最近所有消息/我今天的消息" → `chat message list-all --start <ISO> --end <ISO>`
|
||||
用户说"@我的消息/提及我的" → `chat message list-mentions --start <ISO> --end <ISO>`
|
||||
@@ -1167,7 +1186,7 @@ Flags:
|
||||
用户说"改群名" → `chat group rename`
|
||||
用户说"聊天记录/会话消息/拉取会话" → `chat message list`
|
||||
用户说"某人发给我的消息/指定发送者/某人的消息" → `chat message list-by-sender`(用户未明确说"单聊"时优先使用,跨单聊/群聊)
|
||||
用户说"拉取和某人的单聊记录/单聊消息" → `chat message list --user`(用户明确说"单聊"时使用)
|
||||
用户说"拉取和某人的单聊记录/单聊消息" → `chat message list-direct --user`(单聊专用;用户明确说"单聊"时使用)
|
||||
用户说"@我的消息/at我的/提及我的" → `chat message list-mentions`
|
||||
用户说"未读消息会话/未读会话列表/我的未读会话" → `chat message list-unread-conversations`
|
||||
用户说"发群消息(以个人身份)" → `chat message send --group`
|
||||
@@ -1222,8 +1241,8 @@ Flags:
|
||||
|
||||
关键区分:
|
||||
- `chat search` — 搜**群/会话名**返回 `openConversationId`,**不**搜消息内容;要搜消息内容请用 `chat message search-advanced`(首选)/ `chat message search` / `list-by-sender` / `list-all`,**勿混淆**
|
||||
- `chat message list` — 拉取指定会话的消息(需指定 --group 或 --user),按时间点 + 方向翻页
|
||||
- `chat message list --user` — list 的单聊模式,拉取与指定用户的单聊记录(用户明确说"单聊""私聊"时使用)
|
||||
- `chat message list` — 拉取指定**群聊**的消息(需指定 --group,**仅群聊**),按时间点 + 方向翻页
|
||||
- `chat message list-direct` — 单聊专用,拉取与指定用户的单聊(私聊)记录(--user / --open-dingtalk-id;用户明确说"单聊""私聊"时使用)
|
||||
- `chat message list-by-sender` — 搜索指定发送者发给我的消息,跨所有会话(单聊+群聊均包含,用户只说"某人发的消息"时优先使用)
|
||||
- `chat message list-mentions` — 拉取 @我 的消息(跨单聊/群聊,可选指定群)
|
||||
- `chat message list-unread-conversations` — 拉取当前用户存在未读消息的会话列表(可选 `--count`)
|
||||
@@ -1448,8 +1467,8 @@ Flags:
|
||||
| `chat search` | `openConversationId` | message send/list、group members 等的 --group |
|
||||
| `chat group create` | `openConversationId` | 同上 |
|
||||
| `chat message list-all` | `nextCursor` | 下次 list-all 的 --cursor |
|
||||
| `aisearch person` | `userId` | message send 的 --user、send-by-bot 的 --users、send-by-bot 的 --at-user-ids、list-by-sender 的 --sender-user-id |
|
||||
| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、send-by-bot 的 --open-dingtalk-ids、send-by-bot 的 --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id、message list 的 --open-dingtalk-id |
|
||||
| `aisearch person` | `userId` | message send 的 --user、list-direct 的 --user、send-by-bot 的 --users、send-by-bot 的 --at-user-ids、list-by-sender 的 --sender-user-id |
|
||||
| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、list-direct 的 --open-dingtalk-id、send-by-bot 的 --open-dingtalk-ids、send-by-bot 的 --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id |
|
||||
| `chat bot search` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code(仅我创建的机器人,无 openDingTalkId) |
|
||||
| `chat bot find` | `openDingTalkId` | 给机器人发单聊消息(全部可用机器人,额外返回 openDingTalkId) |
|
||||
| `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys |
|
||||
@@ -1484,7 +1503,7 @@ Flags:
|
||||
- `--group` 为群聊会话 ID (openconversation_id),可从群搜索或群聊信息中获取
|
||||
- `chat message send` 的 text 是位置参数(恰好 1 个),非 flag;群聊用 `--group`,单聊用 `--user`(userId)或 `--open-dingtalk-id`(openDingTalkId),三者互斥;纯文本/Markdown 单聊传 `--user` 时直接走 userId 发送能力;`--at-all`、`--at-open-dingtalk-ids` 仅在 `--group` 群聊时生效;富媒体消息通过 `--msg-type` 指定类型(image/file),必须显式指定;发送文件/媒体消息时,必须先根据文件扩展名判断 msgType:图片→image,其他所有→file,不可跳过此判断
|
||||
- `chat message list-all` 的四个参数(--start、--end、--limit、--cursor)每次请求都必须传递;翻页时用响应中的 nextCursor 值作为下次 --cursor
|
||||
- `chat message list` 的 `--group`、`--user`、`--open-dingtalk-id` 三者互斥,必须且只能指定其一
|
||||
- `chat message list` **仅支持群聊**,必须指定 `--group`;拉取单聊用 `chat message list-direct`(`--user` / `--open-dingtalk-id` 二选一)
|
||||
- `chat message list-by-sender` 不需要指定单聊/群聊,返回结果自带会话类型标识;`--sender-user-id`(userId)与 `--sender-open-dingtalk-id`(openDingTalkId)二选一;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`/`--cursor`
|
||||
- `chat message list-mentions` 可选 `--group` 指定群聊,不传则查全部;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`/`--cursor`
|
||||
- `chat message list-unread-conversations` 获取当前用户未读会话列表,可选 `--count` 指定返回条数
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
工作流:
|
||||
1. 通过 --name 搜索通讯录,获取 userId(或直接传 --user)
|
||||
2. 调用 chat message list --user <userId> 拉取单聊消息
|
||||
2. 调用 chat message list-direct --user <userId> 拉取单聊消息
|
||||
3. 输出到终端或导出为 JSON 文件
|
||||
"""
|
||||
|
||||
@@ -116,7 +116,7 @@ def main():
|
||||
|
||||
while page < max_pages and remaining > 0:
|
||||
cmd_args = [
|
||||
'chat', 'message', 'list',
|
||||
'chat', 'message', 'list-direct',
|
||||
'--user', user_id or '<USER_ID>',
|
||||
'--time', current_time,
|
||||
'--format', 'json',
|
||||
@@ -139,8 +139,12 @@ def main():
|
||||
page_msgs = data
|
||||
has_more = False
|
||||
else:
|
||||
page_msgs = data.get('messages', data.get('result', []))
|
||||
has_more = data.get('hasMore', False)
|
||||
# list-direct 返回 {result: {hasMore, messages: [...]}},先解包 result
|
||||
result_obj = data.get('result', data)
|
||||
if not isinstance(result_obj, dict):
|
||||
result_obj = data
|
||||
page_msgs = result_obj.get('messages', result_obj.get('records', []))
|
||||
has_more = result_obj.get('hasMore', data.get('hasMore', False))
|
||||
|
||||
if not page_msgs:
|
||||
break
|
||||
@@ -153,7 +157,8 @@ def main():
|
||||
break
|
||||
|
||||
last_msg = page_msgs[-1]
|
||||
boundary_time = last_msg.get('createAt') or last_msg.get('time', '')
|
||||
boundary_time = (last_msg.get('createTime') or last_msg.get('createAt')
|
||||
or last_msg.get('time', ''))
|
||||
if not boundary_time or boundary_time == current_time:
|
||||
break
|
||||
current_time = boundary_time
|
||||
@@ -183,9 +188,11 @@ def main():
|
||||
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
|
||||
else:
|
||||
for m in all_messages:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
sender = m.get('senderNick') or m.get('sender', '未知')
|
||||
text = m.get('text') or m.get('content', '')
|
||||
time_str = m.get('createAt') or m.get('time', '')
|
||||
time_str = m.get('createTime') or m.get('createAt') or m.get('time', '')
|
||||
print(f" [{time_str}] {sender}: {text[:80]}")
|
||||
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
|
||||
|
||||
|
||||
@@ -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` | 发布结果(成功或错误信息) | 确认企业技能库已更新 |
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// 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 dws is the module-root package. Its sole purpose is to bake the
|
||||
// skills/ documentation tree into the binary at build time so that
|
||||
// `dws skill setup` installs the skill version shipped with THIS binary,
|
||||
// independent of any local checkout. See internal/app/skill_setup.go.
|
||||
package dws
|
||||
|
||||
import "embed"
|
||||
|
||||
// EmbeddedSkills holds the bundled skills/ tree (mono + multi) compiled into
|
||||
// the binary. The `all:` prefix is required so dot/underscore entries — e.g.
|
||||
// references/best_practices/_common — are included rather than skipped.
|
||||
//
|
||||
//go:embed all:skills
|
||||
var EmbeddedSkills embed.FS
|
||||
@@ -17,11 +17,11 @@ func TestMain(m *testing.M) {
|
||||
absFixture, _ := filepath.Abs("testdata/empty_catalog.json")
|
||||
os.Setenv(cli.CatalogFixtureEnv, absFixture)
|
||||
|
||||
// Serve the local servers.json fixture at /cli/discovery/apis so that
|
||||
// Serve the local servers.json fixture at /cli/discovery/apis/bamboo so that
|
||||
// the dynamic command generator can build CLI commands without network
|
||||
// access. FetchServers calls {baseURL}/cli/discovery/apis.
|
||||
// access. FetchServers calls {baseURL}/cli/discovery/apis/bamboo.
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/cli/discovery/apis", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "testdata/servers.json")
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,11 +17,11 @@ func TestMain(m *testing.M) {
|
||||
absFixture, _ := filepath.Abs("testdata/empty_catalog.json")
|
||||
os.Setenv(cli.CatalogFixtureEnv, absFixture)
|
||||
|
||||
// Serve the local servers.json fixture at /cli/discovery/apis so that
|
||||
// Serve the local servers.json fixture at /cli/discovery/apis/bamboo so that
|
||||
// the dynamic command generator can build CLI commands without network
|
||||
// access. FetchServers calls {baseURL}/cli/discovery/apis.
|
||||
// access. FetchServers calls {baseURL}/cli/discovery/apis/bamboo.
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/cli/discovery/apis", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "testdata/servers.json")
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user