diff --git a/.github/workflows/mirror-to-gitee.yml b/.github/workflows/mirror-to-gitee.yml new file mode 100644 index 00000000..022515cc --- /dev/null +++ b/.github/workflows/mirror-to-gitee.yml @@ -0,0 +1,44 @@ +# 把本仓库代码自动镜像到 Gitee,供国内用户访问(raw 脚本入口 + tags)。 +# 用 HTTPS + 令牌直接 git push(无需 SSH key),复用已配置的 secret: +# GITEE_TOKEN —— Gitee 私人令牌(勾 projects) +# GITEE_USER —— 令牌所属 Gitee 用户名(用于 https 推送鉴权) +# GITEE_REPO —— "owner/repo",如 DingTalk-Real-AI/dingtalk-workspace-cli +# 未配置 GITEE_TOKEN 时(如 fork)自动跳过,不报红叉。 +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 暴露再在 step 守卫。 + env: + GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} + GITEE_USER: ${{ secrets.GITEE_USER }} + GITEE_REPO: ${{ secrets.GITEE_REPO }} + steps: + - name: Checkout (full history + tags) + if: env.GITEE_TOKEN != '' + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Push main + tags to Gitee + if: env.GITEE_TOKEN != '' + run: | + set -eu + REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git" + # 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突) + git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main' + # 镜像对齐(force:Gitee 始终跟随 GitHub) + git push --force "$REMOTE" 'refs/remotes/origin/main:refs/heads/main' + git push --force --tags "$REMOTE" + echo "✅ 已镜像 main + tags 到 Gitee ${GITEE_REPO}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fdee978..158f6730 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 824eba49..c5300ebf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,118 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [1.0.41] - 2026-06-24 + +This release makes the installers work from mainland China out of the box (no env var) and keeps the Gitee mirror in sync automatically. + ### Added -- **`dws auth export` / `dws auth import`** — portable auth bundle for migrating Linux sandbox credentials. Exports the encrypted keychain (`~/.local/share/dws-cli`, including `auth-token.enc` and `dek`) plus required `~/.dws` config so refresh tokens survive import; copying only `app.json` leaves access tokens expiring after ~2 hours. Supports `-o` / `-i` tar.gz paths and `--base64` for copy/paste between sandboxes. `dws auth status` now shows refresh-token validity in table output. +- **Auto-fallback to the Gitee mirror when GitHub is unreachable** (#492; `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh`) — the installers probe GitHub Releases on startup and, when it is unreachable (typical in mainland China), automatically resolve the version and download every asset (binary, `checksums.txt`, `dws-skills.zip`) from the Gitee mirror instead. A plain `curl … | sh` now works in China with no `DWS_GITEE_REPO` needed. Explicit `DWS_GITEE_REPO` still wins, `DWS_NO_FALLBACK=1` forces GitHub, and local source-checkout installs skip the probe. ### Changed -- **Breaking: `dws pat chmod` now prints a compact authorization summary by default** — scripts that parse the raw MCP JSON response from stdout must pass `--format json` or `--verbose` to preserve the previous machine-readable payload. The summary keeps the grant status, agentCode, grantType, scope counts, and next-action hint without dumping full scope detail. +- **CI mirrors repo code to Gitee automatically** (#493; `.github/workflows/mirror-to-gitee.yml`) — the mirror workflow now pushes `main` + tags to the Gitee mirror over HTTPS using `GITEE_TOKEN` (no SSH key), on every push to `main` and every tag, keeping the Gitee `raw/main` install scripts and tags in sync without any manual `git push`. Gated on `GITEE_TOKEN`; skips cleanly when unset. + +## [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`. +- **Skills embedded in the binary** (#488; `skills_embed.go`, `internal/app/skill_setup.go`, `internal/app/skill_setup_embed.go`) — the `skills/` tree (mono + multi) is embedded into the `dws` binary via `go:embed` and `dws skill setup` defaults to the embedded copy, refreshing the installed skill instead of silently reusing a stale copy probed from the current working directory — so skills install offline with no separate download. + +## [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_`), 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//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 `.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 `# ` as the markdown body's first line despite the skill docs saying not to, so duplicate-heading documents kept appearing. The `doc create` helper (which wins the envelope merge via `preferLegacyLeaf`) now strips a leading ATX H1 whose text exactly equals `--name` (trimmed, case-insensitive) before forwarding to `create_document`, printing a stderr note so agents learn the convention. Deliberately conservative: only an exact match is removed (`# 背景` stays), ATX closing hashes are handled without over-trimming names ending in `#` (e.g. `C#`), H2+/setext headings are never touched, and a body that is nothing but the duplicate H1 omits the `markdown` param instead of sending an empty string. JSONML bodies are out of scope. Tests: `TestStripLeadingDuplicateTitleHeading` (9 cases) plus three end-to-end cobra tests asserting the exact `markdown` param sent. + +## [1.0.35] - 2026-06-08 + +### Fixed + +- **`chat message send` @-mentions not rendered in group / direct chat** (#433, `internal/helpers/chat.go`) — when sending a group message or an openDingTalkId direct message (`send_personal_message`) as the current user, the `content` body was packed with `json.Marshal`, whose default HTML escaping turns the `<` `>` in `<@openDingTalkId>` / `<@all>` into `<` `>`. The DingTalk client renders @-mentions by matching the **literal** `<@...>` token, so after escaping the match fails and the mention shows as plain text — while the API still returns `success`, masking the bug. Fix: add `marshalMessageContent`, which serializes `{title,text}` with `json.Encoder` + `SetEscapeHTML(false)`; both the group and openDingTalkId-direct `send_personal_message` paths now use it, preserving the literal `<@...>`. Added regression test `TestChatMessageSendContentNotHTMLEscaped` asserting the content keeps the literal token and is never HTML-escaped. Verified on a real device: `@someone` and `@all` both render as clickable blue mentions. +- **`chat` skill docs & scripts aligned to direct-chat `list-direct`** (#424) — `chat message list` now supports group chats only (`--user` / `--open-dingtalk-id` removed); reading a direct chat moves to the dedicated `list-direct` command, but the skill docs and scripts still taught `chat message list --user`, which now errors with `unknown flag: --user`, also breaking `chat_history_with_user.py` (listed as the "preferred" way to query direct chats). This update: `skills/{mono,multi/dingtalk-chat}/references/products/chat.md` switches `message list` to group-only and documents the new `list-direct` command, syncing the intent routing / key-distinction / context-passing tables / caveats; `skills/mono/references/best_practices/01-messaging.md` changes query-private-chat from `list --user` to `list-direct` (the multi version was already updated); `chat_history_with_user.py` (mono + multi) now calls `list-direct` and fixes response parsing (unwraps `result.messages`, aligns `createTime/content/sender` fields — it previously crashed on `'str' object has no attribute 'get'`). Direct-chat sending still uses `chat message send --user` (since v1.0.34 the direct-send rpc is folded into the `send` command; there is no separate `send-direct`). Docs/scripts only; no change to CLI binary behavior. +- **`pat chmod` batch authorization did not pass through `agentCode`** (#414, `internal/pat/chmod.go`) — the batch plan / grant paths (`buildBatchPlanArgs` / `batchArgs`) previously carried `agentCode` only in the single-grant `toolArgs`; batch calls omitted it, so a batch authorization with an explicit `agentCode` was processed under the default agent. Fix: the batch plan / grant args now also carry `agentCode`, matching the single-grant path. +- **`pat` JSON output escaped the authorization URL into an unreadable form** (#401, `internal/pat`) — the authorization URL attached to PAT error messages, after default HTML escaping, turned `&` into `&`, breaking the link when copied / recognized on mobile. Fix: the PAT error-enrichment JSON output now uses `SetEscapeHTML(false)` (scoped to PAT JSON only), preserving the readable `&` separators. + +## [1.0.34] - 2026-06-03 + +### Changed + +- **Service discovery path now carries a version-coded segment** (`internal/market/registry.go`) — the server-list endpoint moves from `/cli/discovery/apis` to `/cli/discovery/apis/bamboo`. The path is now a single `discoveryAPIPath` constant so future version bumps touch one place. Only the path changes; the MCP base host stays on production `https://mcp.dingtalk.com` and the auth / skill / doctor endpoints are untouched. Discovery via the edition `DiscoveryURL` hook (full-URL `FetchServersFromURL`) is unaffected. Server side must serve the new path. + +### Removed + +- **`dws aiapp` — AI application product taken offline** — removed the `aiapp` product surface (`create` / `query` / `modify`) from the CLI: deleted `internal/helpers/aiapp.go`, dropped it from the generator coverage targets and `knownRegistryProducts`, removed the `aiapp` skill references (mono `references/products/aiapp.md` + `dingtalk-aiapp` multi skill), and unpublished the `aiapp` server from the service-discovery envelope. Product count drops from 19 to 18. + +## [1.0.33] - 2026-06-02 + +This release merges the multi-contributor `pre-mcp-discovery` feature branch into `main` as a single squash (#391), bringing a large batch of new product surface — full DingTalk **docs** (`doc`), **knowledge base** (`wiki`), **AI app** (`aiapp`), AI-table **forms** + **import/export**, and reworked **mail** / **todo** / **report** command trees — while keeping service discovery pinned to production `https://mcp.dingtalk.com` (the branch's `pre-mcp.dingtalk.com` endpoint change was deliberately excluded; the four host constants in `skill_command.go` / `auth/endpoints.go` / `cli/loader.go` / `market/registry.go` stay on prod). It also folds in the portable auth bundle (`dws auth export` / `import`, #357) and PAT batch authorization (#389). + +### Added + +- **`dws doc` — full DingTalk document command family** (#387, #362, #388, #390; `internal/helpers/doc.go`, `internal/helpers/doc_jsonml.go`, `internal/helpers/docjsonml/`) — search / list / info / read / create / update / upload / download / copy / move / rename, plus `file`, `folder`, `block`-level editing and `comment` (list / create / reply / create-inline). Authoring supports both DocxXML and a JSONML format with a v2 schema validator (`docjsonml/jsonml-schema-v2.json` + `doc_jsonml_validate_v2.go`). Document export and OA alignment land here. +- **`dws wiki` — knowledge base management** (`internal/helpers/wiki.go`, `internal/helpers/wiki_proxy.go`) — knowledge space `create` / `get` / `list` / `search` and member `add` / `list` / `update`, routed through a wiki proxy server. +- **`dws aiapp` — AI application lifecycle** (`internal/helpers/aiapp.go`) — `create` (with prompt / attachments / skills), `query` by task ID, `modify` by thread ID. +- **`dws aitable` forms + import/export** (`internal/helpers/aitable_form.go`, `internal/helpers/aitable_export_import.go`) — datasheet form management and full record import/export, the latter driven through the async-task helper for large datasets. +- **Reworked `chat` / `report` / `todo` / `contact` / `mail` command trees aligned to the Wukong baseline** (#355; `internal/compat/mail_hooks.go`, `internal/compat/todo_hooks.go`, `internal/helpers/report_readable.go`) — mail and todo gain dedicated compat hooks; `report` gains a human-readable rendering path alongside the raw JSON, plus deprecation shims for the old report shape. +- **`dws auth export` / `dws auth import`** (#357) — portable auth bundle for migrating Linux sandbox credentials. Exports the encrypted keychain (`~/.local/share/dws-cli`, including `auth-token.enc` and `dek`) plus required `~/.dws` config so refresh tokens survive import; copying only `app.json` leaves access tokens expiring after ~2 hours. Supports `-o` / `-i` tar.gz paths and `--base64` for copy/paste between sandboxes. `dws auth status` now shows refresh-token validity in table output. +- **Async-task and paging infrastructure** (`pkg/asynctask/`, `pkg/paging/`) — shared helpers underpinning long-running operations (e.g. aitable import/export, doc export) and cursor/page traversal. + +### Changed + +- **`envelope` now registers `cli.Aliases` as cobra aliases** (#391) — discovery-generated commands expose their declared aliases natively in the command tree, with accompanying command-structure and JSON-parsing cleanups. +- **Breaking: `dws pat chmod` prints a compact authorization summary by default, and gains batch authorization flows** (#389; `internal/pat/chmod.go`) — scripts that parse the raw MCP JSON from stdout must now pass `--format json` or `--verbose` to keep the machine-readable payload; the default summary keeps grant status, agentCode, grantType, scope counts, and a next-action hint. New batch grant/plan flows (`pat.batch_grant` / `pat.batch_plan`) authorize multiple products in one session, fall back to the legacy single-grant path when the server reports `PAT_BATCH_AUTH_UNSUPPORTED`, use the server's default `agentCode` when none is given, and surface per-tool authorization metadata for grant planning. +- **Skill packs synced to the Wukong-aligned content** across attendance / calendar / minutes / oa / sheet and others (#391). ## [1.0.32] - 2026-05-25 diff --git a/README.md b/README.md index 2e9e5904..e1ba0ae2 100644 --- a/README.md +++ b/README.md @@ -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: @@ -477,28 +499,27 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \ | Service | Command | Commands | Subcommands | Description | |---------|---------|:--------:|-------------|-------------| -| Contact | `contact` | 6 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile | -| Chat / IM | `chat` (alias `im`) | 57 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | Messages (send / reply / list / list-all / by-sender / mentions / focused / unread / topic replies / search / advanced search / forward / cards / emoji & text-emotion reactions / recall / read & send status queries), group CRUD + member management (members add / remove / list / `add-bot`, member-role CRUD, invite URL, icon, settings, transfer-owner, set-admin, quit), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common-groups lookup, group/member/conversation mute, conversation set-top, conversation categories | -| Calendar | `calendar` | 14 | `event` `room` `participant` `busy` | Events CRUD + suggested times + attachments, meeting room booking, free-busy query, participant management | -| Todo | `todo` | 6 | `task` | Create, list, update, done, get detail, delete | -| Approval | `oa` | 9 | `approval` | Approve / reject / revoke, pending / initiated instances, process list, operation records | +| Contact | `contact` | 15 | `user` `dept` `label` `relation` | Search users by name / mobile / job-number, batch query, departments, labels & roles, person relations, roster profile & dismissions, current user | +| Chat / IM | `chat` (alias `im`) | 65 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | Messages (send / reply / list / list-all / by-sender / mentions / focused / unread / topic replies / search / advanced search / forward / cards / emoji & text-emotion reactions / recall / read & send status queries), group CRUD + member management (members add / remove / list / `add-bot`, member-role CRUD, invite URL, icon, settings, transfer-owner, set-admin, quit), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common-groups lookup, group/member/conversation mute, conversation set-top, conversation categories | +| Calendar | `calendar` | 17 | `event` `room` `participant` `busy` | Events CRUD + suggested times + attachments, meeting room booking, free-busy query, participant management | +| Todo | `todo` | 16 | `task` `comment` | Create / list / update / done / get / delete tasks, plus task comments | +| Approval | `oa` | 15 | `approval` | Approve / reject / revoke / redirect tasks, pending / initiated / submitted / executed / cc instances, process forms, comments, operation records | | Attendance | `attendance` | 4 | `record` `shift` `summary` `rules` | Clock-in records, shift schedules, attendance summary, group rules | | Ding | `ding` | 2 | `message` | Send / recall DING messages | -| Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics | -| AI Tables | `aitable` | 42 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` | Full CRUD for Bases / datasheets / records / fields / views; charts & dashboards with public-share configs; data import/export; attachments (prepare-only `upload` + one-shot `upload-file`); templates | -| Doc | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), upload / download | +| Report | `report` | 20 | `create` `submit` `list` `detail` `template` `stats` `inbox` `outbox` `entry` | Create / submit reports, sent & received (inbox / outbox) lists, templates (get / list), statistics, single-entry get | +| AI Tables | `aitable` | 52 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` `form` | Full CRUD for Bases / datasheets / records / fields / views; charts & dashboards with public-share configs; data import/export; attachments (prepare-only `upload` + one-shot `upload-file`); datasheet forms; templates | +| Doc | `doc` | 28 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), upload / download | | Drive | `drive` | 9 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` | DingTalk drive file ops: list spaces, list / info / download, create folders, one-shot `upload` (three-step composite) or two-phase `upload-info` + `commit`, delete | | Minutes | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | List AI meeting notes (mine / shared), details (info / summary / keywords / transcription / todos / batch), title/summary updates, mind map, speaker replace, hot-word, upload session | -| Mail | `mail` | 4 | `mailbox` `message` | List mailbox addresses, KQL message search, get full message content, send email | -| Sheet | `sheet` | 34 | `range` `filter-view` (top-level: `create` `new` `list` `info` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image` `copy_sheet` `update_sheet` `submit_export_job` `query_export_job` `create_filter` `get_filter` `update_filter` `delete_filter` `set_filter_criteria` `clear_filter_criteria` `sort_filter`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read/write/append, dimension ops, cell merge, find/replace, named filter views + sheet-level filters, image write, async export (`submit_export_job` + `query_export_job` — no consolidated `export` in v1.0.25) | -| Wiki | `wiki` | 7 | `space` `member` | Knowledge base management: space `create` / `get` / `list` / `search` + member `add` / `list` / `update` | -| DevDoc | `devdoc` | 1 | `article` | Search the DingTalk Open Platform documentation | -| AI Search | `aisearch` | 1 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) | -| AI App | `aiapp` | 3 | — | AI application lifecycle: `create` (with prompt / attachments / skills) / `query` (by task ID) / `modify` (by thread ID) | +| Mail | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | List mailboxes, KQL message search, read & send messages, drafts, folders, tags, threads, attachments, address-book user search | +| Sheet | `sheet` | 23 | `range` `filter-view` (top-level: `create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read / write / append, dimension ops, cell merge / unmerge, find / replace, named filter views + sheet-level filters, image write | +| Wiki | `wiki` | 21 | `space` `member` `node` `doc` `file` | Knowledge base management: spaces (`create` / `get` / `list` / `search`), members (`add` / `list` / `update`), node tree, docs & files | +| DevDoc | `devdoc` | 2 | `article` `error` | Search Open Platform documentation and troubleshoot API errors | +| AI Search | `aisearch` | 3 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) | | Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives | | Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management | -> **213 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. diff --git a/README_zh.md b/README_zh.md index 6d0f3cff..9f0a91b8 100644 --- a/README_zh.md +++ b/README_zh.md @@ -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 工具就能通过自然语言直接操作钉钉: @@ -473,28 +495,27 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \ | 服务 | 命令 | 命令数 | 子命令 | 描述 | |------|------|:------:|--------|------| -| 通讯录 | `contact` | 6 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 | -| 群聊 | `chat`(别名 `im`)| 57 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | 消息(发送 / 回复 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索 / 高级搜索 / 转发 / 卡片 / 表情与文本表情反应 / 撤回 / 已读与发送状态查询)、群增删改 + 成员管理(成员增 / 删 / 查 / `add-bot`、成员角色增删改查、邀请链接、群图标、群设置、转让群主、设置管理员、退群)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊、群/成员/会话免打扰、会话置顶、会话分类 | -| 日历 | `calendar` | 14 | `event` `room` `participant` `busy` | 日程 CRUD + 建议时间 + 附件、会议室预订、闲忙查询、参与者管理 | -| 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 | -| 审批 | `oa` | 9 | `approval` | 同意 / 拒绝 / 撤销、待我审批 / 我发起的、流程列表、操作记录 | +| 通讯录 | `contact` | 15 | `user` `dept` `label` `relation` | 按姓名 / 手机号 / 工号搜索、批量查询、部门树、角色标签、人员关系、花名册与离职、当前用户信息 | +| 群聊 | `chat`(别名 `im`)| 65 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | 消息(发送 / 回复 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索 / 高级搜索 / 转发 / 卡片 / 表情与文本表情反应 / 撤回 / 已读与发送状态查询)、群增删改 + 成员管理(成员增 / 删 / 查 / `add-bot`、成员角色增删改查、邀请链接、群图标、群设置、转让群主、设置管理员、退群)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊、群/成员/会话免打扰、会话置顶、会话分类 | +| 日历 | `calendar` | 17 | `event` `room` `participant` `busy` | 日程 CRUD + 建议时间 + 附件、会议室预订、闲忙查询、参与者管理 | +| 待办 | `todo` | 16 | `task` `comment` | 创建、列表、修改、完成、详情、删除,以及任务评论 | +| 审批 | `oa` | 15 | `approval` | 同意 / 拒绝 / 撤销 / 转交、待我审批 / 我发起 / 已提交 / 已办 / 抄送、流程表单、评论、操作记录 | | 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 | | DING | `ding` | 2 | `message` | 发送 / 撤回 DING 消息 | -| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、详情、统计 | -| AI 表格 | `aitable` | 42 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` | Base / 数据表 / 记录 / 字段 / 视图 全量 CRUD;图表 + 仪表盘(含分享配置);数据导入导出;附件(仅获取凭证的 `upload` + 一键上传 `upload-file`);模板 | -| 文档 | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | 搜索 / 读写文档、文件与文件夹创建、块级编辑、评论(list / create / reply / create-inline)、上传 / 下载 | +| 日志 | `report` | 20 | `create` `submit` `list` `detail` `template` `stats` `inbox` `outbox` `entry` | 创建 / 提交日志、收发(收件箱 / 发件箱)列表、模版(获取 / 列表)、详情、统计、单条获取 | +| AI 表格 | `aitable` | 52 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` `form` | Base / 数据表 / 记录 / 字段 / 视图 全量 CRUD;图表 + 仪表盘(含分享配置);数据导入导出;附件(仅获取凭证的 `upload` + 一键上传 `upload-file`);数据表表单;模板 | +| 文档 | `doc` | 28 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | 搜索 / 读写文档、文件与文件夹创建、块级编辑、评论(list / create / reply / create-inline)、上传 / 下载 | | 钉盘 | `drive` | 9 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` | 钉盘文件操作:列出空间、文件列表 / 详情 / 下载、创建文件夹、一键 `upload`(三步合成)或两阶段 `upload-info` + `commit`、删除 | | AI 听记 | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | 听记列表(我创建 / 共享给我)、详情(info / summary / keywords / transcription / todos / batch)、标题/摘要更新、思维导图、发言人替换、热词、上传会话 | -| 邮箱 | `mail` | 4 | `mailbox` `message` | 邮箱地址列表、KQL 邮件搜索、邮件详情、发送邮件 | -| 在线电子表格 | `sheet` | 34 | `range` `filter-view`(顶层:`create` `new` `list` `info` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image` `copy_sheet` `update_sheet` `submit_export_job` `query_export_job` `create_filter` `get_filter` `update_filter` `delete_filter` `set_filter_criteria` `clear_filter_criteria` `sort_filter`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并、查找替换、命名筛选视图 + 表级筛选、写入图片、异步导出(`submit_export_job` + `query_export_job`,v1.0.25 暂无合并的 `export` 命令) | -| 知识库 | `wiki` | 7 | `space` `member` | 知识库管理:空间 `create` / `get` / `list` / `search` + 成员 `add` / `list` / `update` | -| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 | -| AI 搜问 | `aisearch` | 1 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) | -| AI 应用 | `aiapp` | 3 | — | AI 应用生命周期:`create`(含 prompt / attachments / skills)/ `query`(按任务 ID)/ `modify`(按 thread ID) | +| 邮箱 | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | 邮箱地址列表、KQL 邮件搜索、读取与发送邮件、草稿、文件夹、标签、会话、附件、通讯录用户搜索 | +| 在线电子表格 | `sheet` | 23 | `range` `filter-view`(顶层:`create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并/取消合并、查找替换、命名筛选视图 + 表级筛选、写入图片 | +| 知识库 | `wiki` | 21 | `space` `member` `node` `doc` `file` | 知识库管理:空间(`create` / `get` / `list` / `search`)、成员(`add` / `list` / `update`)、节点树、文档与文件 | +| 开发者文档 | `devdoc` | 2 | `article` `error` | 搜索钉钉开放平台文档、排查开放平台调用错误 | +| AI 搜问 | `aisearch` | 3 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) | | 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 | | Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token | -> **19 个产品,213 条命令。** 完整命令清单(带描述与使用场景):[`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` 产品。 diff --git a/docs/agent-code.md b/docs/agent-code.md new file mode 100644 index 00000000..f993c9fe --- /dev/null +++ b/docs/agent-code.md @@ -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). diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json deleted file mode 100644 index d92e664d..00000000 --- a/docs/batch-auth-prd.json +++ /dev/null @@ -1,552 +0,0 @@ -{ - "title": "DWS Batch Authorization and Login Recommend Flow", - "version": "2026-06-16", - "owners": { - "cli": "dingtalk-workspace-cli", - "gateway": "lippi-open-mcpserver-gw", - "patCore": "lippi-pat-core-wukong-capability", - "bff": "lippi-open-app-dev", - "frontend": "lippi-open-dev-front" - }, - "context": { - "problem": "DWS login --recommend and product-line batch authorization need a user-visible authorization page instead of silent bulk grants or local --yes-only blocking.", - "reference": { - "larkCli": { - "loginRecommend": "lark-cli auth login --recommend", - "domainAll": "lark-cli auth login --domain all", - "noWait": "lark-cli auth login --no-wait --json", - "deviceCode": "lark-cli auth login --device-code <code>", - "exclude": "lark-cli auth login --exclude <scope>", - "scopeCatalog": "lark-cli auth scopes", - "scopeCheck": "lark-cli auth check --scope <scope...>", - "qrCode": "lark-cli auth qrcode <url>", - "yesFlag": "lark-cli auth login has no --yes flag; lark-cli auth login --yes --no-wait --json returns Error: unknown flag: --yes. DWS intentionally differs because --yes is an inherited batch-auto-confirm flag: dws auth login --recommend --yes must skip the browser confirmation flow and grant the service-selected scopes directly." - }, - "ralphCli": { - "model": "PRD-driven long-running development with explicit task ledger, progress notes, verification gates, and failure analysis.", - "evidence": "ralph v0.16.0 help and /Users/xuan/knowledge/META-ralph-reference.md", - "mapping": "This PRD keeps a single SSOT for goals, contracts, tasks, security gates, acceptance criteria, and blocked evidence so implementation can continue across multiple repositories without redefining done." - }, - "existingDingTalkPage": "/src/app/personalAuthorization/index.jsx", - "targetPage": "Reuse the personalAuthorization route and render multiple scopes grouped by product." - }, - "accessNotes": [ - "The frontend GitLab repo was cloned through SSH to /Users/xuan/lippi-open-dev-front and implemented on branch codex/batch-auth-page-flow.", - "The DWS PAT BFF lives in /Users/xuan/lippi-open-app-dev and needs to be part of the chain because it owns /dingtalk-workspace-cli/oauth/device/action.", - "Acceptance is not complete after local build. It is complete only after prepub package/build succeeds for every changed repo and prepub functional verification passes.", - "The referenced DingTalk minutes URL could not be fetched because local dws is not logged in." - ] - }, - "goals": [ - "After dws auth login --recommend succeeds, DWS starts a PAT recommended batch plan and opens the authorization page.", - "For PAT batch authorization commands through dws pat chmod without --yes, including multi-scope, product-line, and recommend modes, DWS opens the same authorization page instead of returning a local validation error.", - "The server, not the CLI, selects grantable scopes. CLI must grant only selectedScopes from pat.batch_plan.", - "Recommended login scope selection is controlled by prepub Diamond: open-source visible scopes are the enabled template set, recommendation is the low-risk subset, and the 22 medium-risk scopes are excluded from default login recommend.", - "High-risk scopes remain pending and must not be silently granted." - ], - "nonGoals": [ - "Do not trust CLI-provided orgId or uid.", - "Do not add a second authorization UI path.", - "Do not make medium-risk or high-risk destructive scopes part of default login recommend." - ], - "userFlows": [ - { - "name": "loginRecommend", - "steps": [ - "User runs dws auth login --recommend.", - "CLI completes OAuth loopback or device login, lets the user choose the DingTalk organization through the normal login flow, and saves the token.", - "CLI does not show the product-domain TUI for --recommend.", - "CLI calls pat.batch_plan with recommend=true, grantType=permanent, caller=auth_login_recommend, dryRun=true.", - "PAT core loads the Diamond-driven recommended set, excludes non-recommended medium-risk scopes from the default plan, and returns selectedScopes plus warnings/skipped scopes.", - "CLI calls pat.batch_grant with scopes=selectedScopes, startFlow=true, noWait=true, caller=auth_login_recommend.", - "PAT core creates a single batch Device Code Flow and returns PAT_BATCH_AUTH_PENDING with flowId/userCode/uri.", - "CLI opens uri, polls flow status, and retries the original grant after approval." - ] - }, - { - "name": "loginInteractiveTuiWithoutRecommend", - "steps": [ - "User runs dws auth login without --recommend in an interactive terminal.", - "CLI completes OAuth loopback or device login, lets the user choose the DingTalk organization through the normal login flow, and saves the token.", - "After the login identity and organization are settled, CLI shows the Feishu-like terminal authorization guide: 选择操作, then 授权范围选择.", - "CLI calls pat.batch_plan with recommend=true when the user selected 推荐授权, or recommend=false when the user selected 全部授权.", - "CLI shows the product-domain selector with all product lines selected by default; user can toggle, filter, or confirm.", - "CLI re-plans with the selected productCodes before starting the grant flow.", - "Without --yes, CLI calls pat.batch_grant with scopes=selectedScopes, startFlow=true, noWait=true, then opens/polls the returned authorization URL." - ] - }, - { - "name": "batchChmodWithoutYes", - "steps": [ - "User runs dws pat chmod --recommend --product <product> without --yes.", - "CLI plans first, then starts a batch Device Code Flow instead of direct grant.", - "The authorization page displays all grantable selected scopes grouped by product.", - "Approve writes selected grants; reject leaves all grants unchanged." - ] - }, - { - "name": "batchChmodWithYes", - "steps": [ - "User runs the same command with --yes.", - "CLI continues the existing non-interactive grant path.", - "PAT core writes grantable low/medium-risk scopes directly and returns high-risk scopes as pending." - ] - } - ], - "larkCliParity": [ - { - "lark": "auth login --recommend", - "dws": "dws auth login --recommend", - "serverContract": "pat.batch_plan recommend=true caller=auth_login_recommend, then pat.batch_grant selectedScopes startFlow=true noWait=true", - "status": "implemented locally in CLI, gateway, PAT core, BFF, and frontend; prepub package and verification pending" - }, - { - "lark": "auth login --domain <domain>|all", - "dws": "dws pat chmod --product <productCode[,productCode]> or --products <...>", - "serverContract": "pat.batch_plan productCodes=[...] resolves service-owned product scope templates", - "status": "implemented for PAT chmod product-line flow" - }, - { - "lark": "auth login --scope <scopes>", - "dws": "dws pat chmod <scope...>", - "serverContract": "one scope preserves direct grant behavior; multiple scopes enter batch plan/flow when --yes is absent", - "status": "implemented for multi-scope PAT chmod" - }, - { - "lark": "auth login --exclude <scope>", - "dws": "batch authorization page selectedScopes", - "serverContract": "frontend sends selectedScopes; PAT core rejects any selected scope outside flow scopes and treats unchecked flow scopes as rejectedScopes", - "status": "implemented locally in PAT core, BFF selectedScopes passthrough, and frontend grouped selection; prepub verification pending" - }, - { - "lark": "auth login --no-wait --json and later --device-code", - "dws": "PAT_BATCH_AUTH_PENDING JSON with uri/authUrl/flowId/userCode plus polling retry", - "serverContract": "host-owned JSON mode returns structured pending and does not block; local interactive mode opens browser and polls", - "status": "implemented in CLI retry path and PAT pending response" - }, - { - "lark": "auth login --yes", - "dws": "supported only as dws auth login --recommend --yes; inherited --yes skips local TUI and web confirmation and directly grants the PAT-core selected safe recommend scopes", - "serverContract": "auth login --recommend without --yes must still open or return the authorization page. With --yes, the CLI omits startFlow/noWait and relies on PAT core to grant only service-selected safe scopes; server-side pending/high-risk/page-required responses still win.", - "status": "verified on 2026-06-16 with local lark-cli: unknown flag --yes; DWS --yes semantics are product-specific and intentionally bypass the web confirmation page for recommend direct grant" - }, - { - "lark": "auth scopes and auth check", - "dws": "pat.batch_plan dryRun and existing PAT auth check result", - "serverContract": "plan exposes selected/skipped/pending/already-granted scope sets; check/auth retry carries missing scope and grant options", - "status": "partially aligned; no new public scope-catalog command added in this slice" - } - ], - "ralphExecutionPlan": { - "project": "dws-batch-authorization-page", - "tasks": [ - { - "id": "1", - "title": "Build PRD and command parity map", - "description": "Use product-design style requirements plus Ralph-style SSOT to map DWS batch authorization against lark-cli.", - "steps": [ - "Define user flows for login recommend, product-line grant, multi-scope grant, yes direct grant, and JSON no-wait mode.", - "Record frontend access and DingTalk minutes access blockers explicitly.", - "Keep acceptance criteria tied to command outputs and service contracts." - ], - "done": true - }, - { - "id": "2", - "title": "Implement DWS CLI batch authorization entry points", - "description": "dws auth login --recommend and dws pat chmod batch/product/recommend without --yes start the page-based flow.", - "steps": [ - "Add --recommend to auth login and run PAT recommended plan after login.", - "Route product-line, recommend, and multi-scope chmod without --yes to pat.batch_grant startFlow.", - "Keep --yes as the explicit direct-write path." - ], - "done": true - }, - { - "id": "3", - "title": "Harden gateway identity and caller allowlist", - "description": "The gateway must derive orgId/uid from trusted identity and allow intended DWS batch callers.", - "steps": [ - "Keep CLI-provided orgId/uid untrusted.", - "Allow dws-cli alongside openclaw for intended batch write callers.", - "Preserve caller/source checks for default grant writes." - ], - "done": true - }, - { - "id": "4", - "title": "Implement PAT core recommended filtering and batch DCF", - "description": "PAT core owns selectedScopes, Diamond-driven recommendation, batch Device Code Flow, selected approval, and idempotent writes.", - "steps": [ - "Keep recommended scope selection config-driven: preauth Diamond recommends the low-risk subset and excludes the 22 medium-risk scopes.", - "Create/reuse one batch flow for selected scopes.", - "Approve only selectedScopes and reject out-of-flow selections.", - "Fail closed on critical storage failures and leave flow retryable." - ], - "done": true - }, - { - "id": "5", - "title": "Implement lippi-open-dev-front batch authorization UI", - "description": "Reuse /src/app/personalAuthorization/index.jsx and render grouped product permissions with selectable scopes.", - "steps": [ - "Load flow detail and detect multiple scopes.", - "Default-check all server-selected flow scopes.", - "Allow product and individual-scope deselection.", - "Submit selectedScopes on approve and no orgId/uid.", - "Handle PAT_BATCH_PARTIAL_FAILURE by keeping the page retryable." - ], - "done": true - }, - { - "id": "6", - "title": "Submit all repos to prepub and verify package plus behavior", - "description": "Acceptance only passes after prepub package/build succeeds and prepub functional verification is OK.", - "steps": [ - "Push or submit branches for CLI, gateway, PAT core, BFF, and frontend.", - "Trigger prepub package/build for every changed repo.", - "Verify prepub deployed versions include the current change set.", - "Run login recommend and batch chmod functional checks on prepub.", - "Open the prepub authorization page and verify layout and approve/reject callbacks." - ], - "done": false - } - ] - }, - "prepubReleaseGate": { - "overallStatus": "not_passed_until_prepub_validation_and_full_batch_page_e2e_pass", - "changedRepos": [ - { - "repo": "dingtalk-workspace-cli", - "localPath": "/Users/xuan/dingtalk-workspace-cli", - "branch": "codex/dws-auth-login-recommend", - "commit": "f0552dd20e6a722add6ae0af366abd9ec8e753b7", - "prepubPackage": "pushed to GitHub branch codex/dws-auth-login-recommend with CLI commit f0552dd20e6a722add6ae0af366abd9ec8e753b7; rebuilt local default /usr/local/bin/dws -> /Users/xuan/bin/dws and /Users/xuan/.local/bin/dws from current source with identical sha256 22c18538db5dd7d5d32557e2183646fd6535824fb8bd9eb4b67251b4ec4dfc75 before the latest docs-only evidence commit. Local focused and broad Go regressions passed after the auth-login wait-only fix. Still blocked_no_aone_app_for_current_github_repo_lippi_dws_release_uses_gitlab_release_repo for formal prepub package evidence.", - "prepubFunctionalVerification": "local_cli_tui_contract_ok: dws auth login --recommend skips terminal TUI/product-domain selection and directly uses server recommend=true selectedScopes. Interactive default dws auth login without --recommend follows the terminal usage chain after login/org selection: 选择操作, 选择授权范围, then default-all-selected product domains before grant. 推荐授权 uses recommend=true; 全部授权 first discovers products with recommend=true, then replans selected product domains with recommend=false only after selected productCodes are available; a local guard rejects recommend=false with empty productCodes before any PAT call to avoid PAT_BATCH_AUTH_SCOPE_REQUIRED. explicit --format json and any non-TTY stdin/stdout/stderr keep structured non-interactive mode. local_wait_only_contract_ok: after the auth-login batch authorization page is approved, CLI prints the final success result and returns; it no longer prints 授权完成,正在重试... and no longer re-enters the post-login TUI flow. local_yes_contract_ok: dws auth login --recommend --yes skips the TUI/browser flow and calls pat.batch_grant without startFlow/noWait, preserving direct recommended direct grant behavior." - }, - { - "repo": "lippi-open-mcpserver-gw", - "localPath": "/Users/xuan/lippi-open-mcpserver-gw", - "branch": "codex/pat-batch-auth-service-gate-20260616", - "commit": "89f6ea8c16cf95678e999d082e3fe94a578d0b6a", - "aoneAppId": 296391, - "aoneCrId": 34656146, - "aoneCrStatus": "INTG", - "aoneCrStatusLabel": "发布中", - "prepubPipelineId": 66, - "prepubPipelineInstanceId": 3095206675, - "prepubPackage": "Aone CR 34656146 pipeline 66 instance 3095206675 has code merge SUCCESS tasks 3391854522/3391854523, build+scan SUCCESS tasks 3391854524/3391854525, prepub deploy SUCCESS task 3391854526 completed 2026-06-18T02:11:34+08:00, and prepub integration SUCCESS task 3391854527. It is RUNNING/WAITING in prepub verification task 3391854528. CR newestRevision/current pushed head is 89f6ea8c16cf95678e999d082e3fe94a578d0b6a.", - "prepubFunctionalVerification": "prepub_deploy_and_integration_success_for_login_recommend_default_compat; full_batch_auth_e2e_pending" - }, - { - "repo": "lippi-pat-core-wukong-capability", - "localPath": "/Users/xuan/lippi-pat-core-wukong-capability", - "branch": "codex/batch-auth-page-flow", - "commit": "dab26d3474ded776e7e1abb6419e75bdb43e75bf", - "aoneAppId": 312546, - "aoneCrId": 34656143, - "aoneCrStatus": "INTG", - "aoneCrStatusLabel": "发布中", - "aoneBranchRevision": "b38e29e9e9c0f55dc71762c37d261180cb30692b", - "aoneNewestRevision": "dab26d3474ded776e7e1abb6419e75bdb43e75bf", - "prepubPipelineId": 66, - "prepubPipelineInstanceId": 3095206676, - "prepubPackage": "Aone CR 34656143 pipeline 66 instance 3095206676 has code merge SUCCESS tasks 3391854529/3391854530, build+scan SUCCESS tasks 3391854531/3391854532, prepub deploy SUCCESS task 3391854533 completed 2026-06-18T02:10:33+08:00, and prepub integration SUCCESS task 3391854534. It is RUNNING/WAITING in prepub verification task 3391854535. CR newestRevision/current pushed head is dab26d3474ded776e7e1abb6419e75bdb43e75bf.", - "prepubFunctionalVerification": "prepub_deploy_and_integration_success_for_login_recommend_default_compat; full_batch_auth_e2e_pending" - }, - { - "repo": "lippi-open-app-dev", - "localPath": "/Users/xuan/lippi-open-app-dev", - "branch": "codex/batch-auth-bff-selected-scopes", - "commit": "df53f856c31be3abee220b738022f861cbf78b93", - "aoneAppId": 154450, - "aoneCrId": 34656145, - "aoneCrStatus": "PREINTG", - "aoneCrStatusLabel": "待发布", - "aoneBranchRevision": "f4222f3b2acba1899446d338c4b3788b8a58b29f", - "aoneNewestRevision": "df53f856c31be3abee220b738022f861cbf78b93", - "prepubPipelineId": 66, - "prepubPipelineInstanceId": 3095182503, - "prepubPackage": "Aone CR 34656145 pipeline 66 instance 3095182503 has code merge/build/prepub deploy/prepub integration SUCCESS and is WAITING in prepub verification task 3391762849. The previous code-merge CONFLICT is no longer active. Deploy orders 155430894/155430896 succeeded at 2026-06-17 21:21-21:32+08:00 for 预发环境 and 新加坡预发; task 3391762848 prepub integration completed SUCCESS at 2026-06-17 21:44:04+08:00. CR newestRevision/current head is df53f856c31be3abee220b738022f861cbf78b93.", - "prepubFunctionalVerification": "prepub_deploy_and_integration_success; selectedScopes_fail_closed_local_tests_passed; full_batch_auth_e2e_pending" - }, - { - "repo": "lippi-open-dev-front", - "localPath": "/Users/xuan/lippi-open-dev-front", - "branch": "codex/batch-auth-page-flow", - "commit": "aafa687c3ce23a9292c7890d9af40a18ce07fb2a", - "aoneAppId": null, - "aoneCrId": null, - "prepubPackage": "O2 assets app still does not support pub-env=pre. Latest branch head aafa687c3ce23a9292c7890d9af40a18ce07fb2a is pushed and local npm run build passed with existing bundle-size warnings only. The latest recorded non-production daily assets build/deploy is still task 149432767 for commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4, build id 53073038, asset count 203, published to dev.g.alicdn.com/dingding/lippi-open-dev-front/5.219.0/; a new O2/daily package for aafa687c3ce23a9292c7890d9af40a18ce07fb2a is still pending.", - "prepubFunctionalVerification": "frontend_local_build_ok_for_account_subtitle_centering_and_batch_description_fix; latest_daily_cdn_package_pending; full_e2e_pending_server_current_heads", - "o2AppId": 8780, - "o2IterationId": 16804012, - "o2TaskId": 149432767, - "o2FlowId": 114592657, - "o2ReleaseVersion": "5.219.0", - "o2PublishEnv": "daily" - } - ], - "requiredEvidence": [ - "Every changed repo has a pushed branch or prepub release submission tied to the current change set.", - "Prepub package/build succeeds for CLI, gateway, PAT core, BFF, and frontend.", - "Prepub deployed versions include the selectedScopes frontend-to-BFF-to-PAT core chain.", - "Prepub functional verification covers login recommend, chmod without --yes page flow, chmod with --yes direct flow, selectedScopes subset approval, reject, Diamond-driven low-risk recommendation, high-risk pending, and JSON no-wait pending response.", - "Prepub UI screenshot matches visualDesignTarget.", - "Frontend O2 assets app uses daily as the available non-production channel because prepub is unsupported for assets pub type." - ], - "latestPrepubVerification": { - "verifiedAt": "2026-06-16T17:22:12+08:00", - "recommendationPolicy": { - "sourceOfTruth": "prepub Diamond plus product_scope_template DB", - "openWhitelistDataId": "com.dingtalk.pat.opensource-scope-whitelist", - "openWhitelistScopeCount": 173, - "preauthDataId": "com.dingtalk.pat.wukong-preauth-config", - "preauthVersion": "2026-06-16-dws-low-risk-151-preauth-v1", - "recommendedLowRiskScopeCount": 151, - "excludedMediumRiskScopeCount": 22, - "slsReceipt": "2026-06-16 17:19:26 prehost logs: OpenSourceScopeWhitelistConfigManager scopeCount=173; WukongPreAuthConfigManager recommendAllScopes=false scopeCount=151" - }, - "dryRun": { - "command": "go run ./cmd/main.go pat chmod --recommend --dry-run --agentCode dingmbw5n9ktkkbbjv3g --format json", - "result": "items=151, selected=43, skipped=108, risk_counts={LOW:151}, skipped_reasons={already granted:108}", - "localReceipt": "/tmp/dws-pat-chmod-recommend-dryrun-after-diamond.json" - }, - "loginRecommendApproval": { - "command": "go run ./cmd/main.go auth login --recommend --format json", - "flowId": "a4ef35755fb04c248b0d4319ce73651c", - "userCode": "E9CL-YJCV", - "authorizationUrl": "https://pre-open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Da4ef35755fb04c248b0d4319ce73651c%26userCode%3DE9CL-YJCV#/personalAuthorization?flowId=a4ef35755fb04c248b0d4319ce73651c&userCode=E9CL-YJCV", - "pageObservedText": "DingTalk-Workspace请求你的授权; 可授权的权限(已选43/43项); 同意并授权; 授权成功", - "slsReceipt": "DeviceCodeFlowService.grantApprovedScopesBatch approved permanent via batch insert, flowId=a4ef35755fb04c248b0d4319ce73651c, orgId=439446171, uid=2010955235, agentCode=dingmbw5n9ktkkbbjv3g, scopeCount=43" - }, - "databaseEvidence": { - "dmsGroup": "lippi_pat_core_product_logic", - "db": "product", - "agentCode": "dingmbw5n9ktkkbbjv3g", - "orgId": 439446171, - "uid": 2010955235, - "lowRiskRecommendedGranted": "151 rows, status=1, grant_scope=3", - "selectedIncrementGranted": "43 rows", - "activeMediumRiskGrants": "18 rows existed as historical grants; they were not part of this recommend plan because dry-run risk_counts had no MEDIUM rows", - "resultFiles": [ - "/Users/xuan/dms-alibaba/db-groups/lippi_pat_core_product_logic/sql/quick_product/_results/2026-06-16/172211_product.json", - "/Users/xuan/dms-alibaba/db-groups/lippi_pat_core_product_logic/sql/quick_product/_results/2026-06-16/172212_product.json" - ] - }, - "currentSmoke": { - "verifiedAt": "2026-06-17T23:10:00+08:00", - "command": "/usr/local/bin/dws auth login --recommend --no-browser --format json --verbose; /Users/xuan/.local/bin/dws auth login --recommend --no-browser --format json --verbose; DINGTALK_DWS_AGENTCODE=dingmbw5n9ktkkbbjv3g dws pat chmod --recommend --dry-run --format json", - "result": "both rebuilt dws binary paths exited 0, printed 推荐权限已全部授权或没有可授权项, and returned token_valid=true without PAT_BATCH_AUTH_SCOPE_REQUIRED; pat chmod recommend dry-run returned success=true, code=OK, allGranted=true, items=151, selectedScopes=0, skippedScopes=151. /usr/local/bin/dws, /Users/xuan/bin/dws, and /Users/xuan/.local/bin/dws were rebuilt from current source and share sha256 e6b6fe37e1a7503a3b83486ce830b025f476b6e77ddcb383a1ed237b92f2b31f", - "serverPrepubState": "gateway instance 3095206675, PAT instance 3095206676, and BFF instance 3095182503 all have code merge/build/prepub deploy/prepub integration SUCCESS and are WAITING in prepub verification" - }, - "notes": [ - "2026-06-18 compatibility fix: repeated PAT_BATCH_AUTH_SCOPE_REQUIRED was traced to legacy caller-only login recommend requests. Current prepub SLS for auth_login_recommend shows new CLI requests already carry recommend=true/productCodes=[]/scopes=null and PAT core had 0 recent PAT_BATCH_AUTH_SCOPE_REQUIRED hits; nevertheless gateway branch codex/pat-batch-auth-service-gate-20260616 now normalizes pat.batch_plan caller=auth_login_recommend with no scopes/productCodes/recommend to recommend=true, and PAT core branch codex/batch-auth-page-flow applies the same default only when request.recommend is null. Verification: uv run python -m pytest test/test_pat_batch_bridge.py -q => 27 passed; JAVA_HOME=Azul 11 mvn -pl lippi-pat-core-service -am -Dtest=com.dingtalk.pat.manager.PatBatchAuthManagerTest -DfailIfNoTests=false -DfailIfNoSpecifiedTests=false -Dsurefire.failIfNoSpecifiedTests=false test -s /Users/xuan/Downloads/settings.xml => BUILD SUCCESS, Tests run: 24, Failures: 0, Errors: 0. Prepub redeploy evidence: gateway Aone pipeline 66 instance 3095206675 and PAT core instance 3095206676 both reached code merge/build/prepub deploy/prepub integration SUCCESS and are waiting only at manual prepub verification.", - "2026-06-18 CLI interaction fix: auth login post-login authorization now waits for the PAT batch authorization page to complete and then returns; it no longer runs the original TUI/plan/grant function again after APPROVED, so the terminal will not show 授权完成,正在重试... followed by a second 选择要授权的业务域. Verification: env GOCACHE=/tmp/go-cache go test ./internal/app -run 'TestRunDirectPATAuthCheckWaitOnly_ApprovedDoesNotRetry|TestRunDirectPATAuthCheck_ApprovedRetriesCallback|TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat|TestAuthLoginRecommendSkipsPostLoginTUI' => ok; env GOCACHE=/tmp/go-cache go test ./internal/app ./internal/pat ./internal/errors ./internal/auth ./test/unit => ok. Local default binaries /Users/xuan/bin/dws and /Users/xuan/.local/bin/dws rebuilt from current source with sha256 22c18538db5dd7d5d32557e2183646fd6535824fb8bd9eb4b67251b4ec4dfc75." - ] - }, - "latestDiagnostics": [ - "PAT core Aone compile error at PatAuthCheckService.java:517 was caused by a two-argument tryConsumeOnceTokenFromTair call after the method signature changed to require effective risk level. Current feature and release-merge worktrees use RiskLevelEnum.MEDIUM.getCode() for the high-risk-to-medium DCF bypass path and compile locally.", - "The Maven warning about requested profile lippi-pat-core is an Aone/Maven parameter warning: the root POM has no profile with id lippi-pat-core. It is not the cause of the Java compilation failure and does not justify a POM change in this slice.", - "PAT core API version is explicitly aligned: root property lippi-pat-core-api.version is 1.0.5-SNAPSHOT and lippi-pat-core-api/pom.xml project.version evaluates to 1.0.5-SNAPSHOT. Verification passed with Java 11 Maven help:evaluate and lippi-pat-core-api compile.", - "PAT core root POM now defines lippi-enterprise-soc-api.version as 1.0.30-SNAPSHOT, matching local reference repos /Users/xuan/lippi-pat-core and /Users/xuan/lippi-pat-core-prepub-merge-34465221. Verification passed with Java 11 Maven lippi-pat-core-service compile.", - "PAT core Aone build task 3390400102 failed because release branch commit 3555c3aa resolved lippi-open-app-dev-api as 1.0.103, which lacks PatConnectorQueryService and PatConnectorQueryResult. Feature branch commit 78c1c311e91e2c2d204622b6219b8c1a179b6932 changes lippi-open-app-dev.version to 1.2.0-SNAPSHOT, matching /Users/xuan/lippi-pat-core and release merge worktree. Local Java 11 lippi-pat-core-service compile passed and the 1.2.0-SNAPSHOT jar contains both missing classes.", - "PAT core prepub runtime failure for flowId 8b35d5b6373c4cb9a7a67b339516fff2 was TDDL-4603 ERR_ACCROSS_DB_TRANSACTION. The failing path approved a batch flow by looping single-scope INSERT INTO agent_pat in one transaction, crossing sharding groups. Commit 0b805ff changes permanent/session batch approval and direct batch grant to PatAuthGrantService#grantScopesBatch, which uses AgentPatDAO.batchInsert and PatAuthRecordDAO.batchInsert where applicable, while DeviceCodeFlowService#handleFlowAction runs with Propagation.NOT_SUPPORTED to avoid opening a strict cross-shard transaction around the flow action.", - "PAT core local regression passed on 2026-06-16 with Java 11: mvn -pl lippi-pat-core-service -Dtest=PatAuthGrantServiceBatchTest,PatBatchGrantIdempotencyTest,DeviceCodeFlowServiceTransactionTest,DeviceCodeFlowServiceBatchSelectionTest test -s /Users/xuan/Downloads/settings.xml and full mvn -pl lippi-pat-core-service test -s /Users/xuan/Downloads/settings.xml.", - "PAT core Aone pipeline 66 instance 3094916508 reached SUCCESS for code merge, build, prepub deploy, and prepub integration test; prepub validation remained RUNNING when checked.", - "BFF now depends on com.dingtalk.pat:lippi-pat-core-api:1.0.5-SNAPSHOT and uses HandleFlowActionRequest#setSelectedScopes directly. The old reflection fallback has been removed.", - "BFF local verification passed with Java 11: mvn -pl lippi-open-app-dev-start -am -DskipTests compile -s /Users/xuan/Downloads/settings.xml.", - "BFF latest branch head is df53f856c31be3abee220b738022f861cbf78b93. It rejects malformed HTTP selectedScopes before HSF: missing field remains legacy-compatible and forwards null, but present null/non-array/non-string/blank entries return INVALID_PARAM_TYPE and skip PatAuthService#handleFlowAction. Verification passed with Java 11: mvn -pl lippi-open-app-dev-start -am -Dtest=com.dingtalk.open.appdev.http.DWSPatAuthRpcServiceTest -DfailIfNoTests=false -DfailIfNoSpecifiedTests=false test -s /Users/xuan/Downloads/settings.xml. The first Java 22 attempt failed on old aspectjweaver Zip64/PowerMock compatibility, so Java 11 remains the recorded build runtime.", - "Gateway latest branch head is a0873a6a98b96eaa86a1e8e9f75726a3f8dc16a9. It rejects pat.batch_grant calls that carry plan-only recommend/productCodes fields, normalizes non-empty selected scopes before forwarding to PAT core, and has local verification uv run python -m pytest test/test_pat_batch_bridge.py -q: 26 passed.", - "Frontend latest branch head is 356dcbab5d02c9f0225baabbd80a98b28d6f92b3. It only renders the batch page when the server explicitly marks batchFlow/isBatchFlow/flowType=BATCH, and reject success falls back to FLOW_REJECTED so the terminal page shows 已拒绝授权 instead of 授权失败. Verification: npm run build passed with existing bundle-size warnings.", - "lark-cli parity check on 2026-06-16: lark-cli auth login --help does not list --yes, and lark-cli auth login --yes --no-wait --json exits with Error: unknown flag: --yes. Lark recommended login therefore still requires Device Flow/browser authorization and does not provide a --yes bypass.", - "Non-PAT side verification on 2026-06-16 passed: CLI go test ./internal/app ./internal/pat ./internal/errors, gateway py_compile plus diff check, and frontend npm run build with existing bundle-size warnings only.", - "2026-06-16 23:59+08:00 frontend review follow-up: src/app/personalAuthorization/index.jsx now matches requested batch copy: title 可授权的权限, product group count as N 项, footer link 个人数据授权管理. Commit 065178a7011174c14952c89992022968a8a49a7b pushed to codex/batch-auth-page-flow.", - "Frontend O2 release route confirmed: app lippi-open-dev-front id=8780, iteration 16804012 named pat页面授权, branch codex/batch-auth-page-flow. O2 iteration id 86573 from the provided Dbase URL maps to an old pmod iteration in a1 o2, so 16804012 is the correct O2 release target discovered from app 8780.", - "Frontend O2 non-production task 149432667 succeeded for commit 065178a7011174c14952c89992022968a8a49a7b, flow 114592128, version 5.219.0, pub_env daily. Attempting pub_env pre returned: assets does not support prepub.", - "Independent cross-repo review found no P0/P1 contract break in CLI -> gateway -> BFF -> PAT -> frontend for selectedScopes. The important contract is that gateway pat.batch_grant takes scopes, while CLI/BFF use selectedScopes only at their own API boundaries before converting/passing to PAT.", - "CLI recommend login TUI default-selects all product domains before plan/grant, matching the Lark-like pre-authorization selector requirement. Local verification passed with go test ./internal/pat ./internal/app.", - "Frontend CDN verification after O2 task 149432667: https://dev.g.alicdn.com/dingding/lippi-open-dev-front/5.219.0/bundle.js contains 个人数据授权管理 and 可授权的权限, and no longer contains 应用授权管理 or 可授权的权限(已选.", - "2026-06-17 CLI fix: Bacon review found plain dws auth login --recommend skipped the Lark-like product selector because global --format defaults to json. Commit 9ee8ea552406d1804f062f2f9165a031b1f87ab3 changes auth login recommend selector gating so default json still shows the TUI in an interactive terminal, while explicit --format json and non-interactive shells skip it. Verification: go test ./internal/app ./internal/pat passed.", - "2026-06-17 local default dws binary refreshed: /usr/local/bin/dws -> /Users/xuan/bin/dws was rebuilt from dingtalk-workspace-cli commit 9ee8ea552406d1804f062f2f9165a031b1f87ab3; dws version --format json reports version dev, edition open.", - "2026-06-17 CLI follow-up: commit 70b9f39715566a8306f8d437cc62ad2f12934449 changes interactive dws auth login --recommend to use a human recommend flow when --format is not explicitly set. This prevents the PAT authorization wait path from falling back to machine JSON after the TUI. Default interactive behavior is now: login success summary, all-selected product-domain TUI, server re-plan, complete authorization URL print/open, browser approval polling. Explicit --format json and non-interactive shells remain structured/no-block. Verification: go test ./internal/app ./internal/pat passed, and /usr/local/bin/dws -> /Users/xuan/bin/dws was rebuilt from this commit.", - "2026-06-17 CLI TUI design follow-up: commit 5e1983b3df4a35f2e694972ecfc4207132c79671 aligns the recommend selector with the MGDone terminal target: title 选择要授权的业务域, description 空格选择 回车确认, default-selected all product domains, and product labels rendered as productCode + productName + summary, e.g. approval 审批 - 审批实例,审批模板,审批任务管理. It also tightens interactive detection to require stdin/stdout/stderr all be TTY so piped systems stay in structured mode. Verification passed: go test ./internal/app ./internal/pat ./internal/errors; go test ./test/unit; /usr/local/bin/dws -> /Users/xuan/bin/dws rebuilt from this commit.", - "2026-06-17 CLI --yes follow-up: commit 3ec35e30e828b088a8e9415d76804f56feed6354 fixes dws auth login --recommend --yes to behave like batch auto-confirm: resolveAuthLoginConfig reads inherited --yes, recommend human TUI is skipped, and RunLoginRecommendAuthorizationWithOptions calls pat.batch_grant without startFlow/noWait so no browser authorization page is created. No --yes keeps the previous TUI plus batch authorization page flow. Verification passed: go test ./internal/pat -run TestLoginRecommendAuthorization; go test ./internal/app -run TestAuthLoginRecommendSelectorRespectsExplicitFormat|TestLoginRecommendProductLabelMatchesTUITarget|TestResolveAuthLoginConfigReadsInheritedYes; go test ./internal/app ./internal/pat ./internal/errors; go test ./test/unit; /usr/local/bin/dws -> /Users/xuan/bin/dws rebuilt from this commit.", - "2026-06-17 CLI TUI chain correction: commit 2264743e78f71d3664b21d90e3a6a36f383beed7 changes TUI from a single product selector into the intended terminal usage chain from the MGDone screenshots. Interactive dws auth login --recommend now shows 选择操作 (直接使用CLI / 一键配置智能体应用 / 手动输入应用凭证), then 选择授权范围 (推荐授权 / 全部授权), then the default-all-selected 选择要授权的业务域 product-domain multiselect. 推荐授权 keeps recommend=true; 全部授权 replans with recommend=false for selected product domains. Complete runtime AppKey/AppSecret overrides now skip MCP clientId fetch so manual credentials are real rather than decorative. Verification passed: go test ./internal/pat -run TestLoginRecommendAuthorization; go test ./internal/app -run TestAuthLoginRecommendSelectorRespectsExplicitFormat|TestLoginRecommendProductLabelMatchesTUITarget|TestResolveAuthLoginConfigReadsInheritedYes; go test ./internal/auth -run TestDeviceFlow|TestOAuth|TestCredential|TestReset|TestAppConfig|TestClient; go test ./internal/app ./internal/pat ./internal/errors ./internal/auth; go test ./test/unit; /usr/local/bin/dws -> /Users/xuan/bin/dws rebuilt from this commit.", - "2026-06-17 CLI TUI sequence correction: commit 537719c677a9c79d8c45115c7bbbb29fff8dfe1f moves the terminal authorization guide into runRecommend, so dws auth login --recommend first completes DingTalk login, organization selection, and token persistence, then starts 选择操作 -> 选择授权范围 -> default-all-selected 选择要授权的业务域. A new app-layer regression test asserts the token is already readable before any TUI selector runs. Verification passed: go test ./internal/app -run TestAuthLoginRecommendTUIRunsAfterLoginTokenSaved|TestAuthLoginRecommendSelectorRespectsExplicitFormat|TestLoginRecommendProductLabelMatchesTUITarget|TestResolveAuthLoginConfigReadsInheritedYes; go test ./internal/pat -run TestLoginRecommendAuthorization; go test ./internal/app ./internal/pat ./internal/errors ./internal/auth; go test ./test/unit; go build -o /Users/xuan/bin/dws ./cmd/main.go; /usr/local/bin/dws version --format json reports version dev, edition open.", - "2026-06-17 CLI TUI condition correction: dws auth login --recommend no longer shows 选择操作, 授权范围选择, or product-domain TUI. It directly calls pat.batch_plan recommend=true and grants the server-selected recommended scopes. The post-login TUI now belongs only to interactive dws auth login without --recommend, after login/org selection/token persistence. Verification target: TestAuthLoginRecommendSkipsPostLoginTUI and TestAuthLoginWithoutRecommendTUIRunsAfterLoginTokenSaved.", - "2026-06-17 CLI all-scope TUI bugfix: repeated PAT_BATCH_AUTH_SCOPE_REQUIRED errors were caused by the no --recommend TUI path selecting 全部授权 before product selection, which sent an initial pat.batch_plan with recommend=false and empty productCodes. The fix makes the first TUI discovery plan always use recommend=true when no productCodes are known, then applies recommend=false only on the second plan after selected productCodes are available. Regression coverage: TestLoginRecommendAuthorizationAllScopeModePlansAllProductScopes and TestAuthLoginWithoutRecommendTUIRunsAfterLoginTokenSaved.", - "2026-06-17 CLI current-head verification: commit dcf132a7e3223288a7fc82882560ab642fb63899 is pushed to origin/codex/dws-auth-login-recommend and /usr/local/bin/dws points to /Users/xuan/bin/dws rebuilt from that source. Verification passed: go test ./internal/app ./internal/pat ./internal/errors ./internal/auth ./test/unit; /usr/local/bin/dws auth login --recommend --format json completed without PAT_BATCH_AUTH_SCOPE_REQUIRED and printed 推荐权限已全部授权或没有可授权项 for the current account. The earlier diagnostics that described --recommend showing a TUI are superseded; the final rule is --recommend direct plan/grant, and the terminal TUI is only for interactive dws auth login without --recommend.", - "2026-06-17 MGDone MCP receipt: Cursor mastergo-magic-mcp server was started from /Users/xuan/.cursor/mcp.json with token redacted. tools/list exposed mcp__getDesignSections/mcp__getDsl/mcp__getDesignSvgs/mcp__getDesignTexts. mcp__getDesignSections for fileId=187681481694834, layerId=74:36608 returned design-sections API not available; fallback mcp__getDsl with the same page_id returned an empty nodes/components DSL. Therefore current terminal implementation uses the visible MGDone screenshot target for TUI text/label/control behavior and records that a true layer_id/source_layer_id is still needed for pixel-level DSL extraction.", - "2026-06-17 frontend account-row fix: commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4 makes the authorization account row prefer userAvatar/userName before orgLogo/orgName to avoid mixed org icon + user name rendering. Verification: npm run build passed with existing bundle-size warnings only.", - "Frontend O2 non-production task 149432767 succeeded for commit f33e349b2c29c7ba139e48fdfbdd70759ce3a4c4, flow 114592657, version 5.219.0, pub_env daily, build id 53073038, assetCount 203. CDN bundle contains userAvatar|| and no longer contains the old orgLogo|| account-row priority; old title 可授权的权限(已选 remains absent.", - "2026-06-17 server release audit: gateway current head a0873a6 and PAT current head 79a8fa5 are not proven deployed because their successful prepub deploy receipts predate the current commits. BFF current head 9ac576c is also not proven deployed by deploy-order revision: latest pipeline 3095011879 deployed, but deploy-order 155348765 records revision f4222f3b2acba1899446d338c4b3788b8a58b29f and integration-test task 3390965943 is FAIL. Gateway deploy-order 155284562 records revision ffaa951f13a107a08fb04c6246ba99bf7d583477; PAT deploy-order 155318893 records revision b38e29e9e9c0f55dc71762c37d261180cb30692b. Full prepub e2e remains blocked on current-head redeploy plus BFF integration-test resolution or accepted exception.", - "2026-06-17 15:46+08:00 Aone recheck: gateway pipeline 66 instance 3095046153 has deploy/integration SUCCESS but deploy orders still show deployed branchRevision ffaa951, one commit behind CR newestRevision/current head a0873a6; PAT pipeline 66 instance 3095097702 has deploy/integration SUCCESS but deploy order 155387876 still shows deployed branchRevision b38e29e, 12 commits behind CR newestRevision/current head 79a8fa5; BFF pipeline 66 instance 3095097708 is WAITING at code merge task 3391355335 with build/deploy INIT, and latest successful deployed revision remains f4222f3, 199 commits behind current head 9ac576c. Therefore PRD acceptance remains not passed until current revisions are demonstrably active and functional verification is rerun.", - "2026-06-17 16:06+08:00 Aone task drill-down: BFF task 3391355335 progress says 合并代码存在冲突,请查看冲突并解决. componentData.status=CONFLICT and nextSteps instruct resolving change 34571941 from feature/robot-openapi-pop revision db24ed483eaa89a59fd34f763cfb2ee4adf45c06 into release branch releases/20260616153930479_r_release_154450_lippi-open-app-dev-code, then rerunning a1 app pipeline. Gateway verification task 3391110450 is CANCEL; PAT verification task 3391355313 remains WAITING for CR34656143.", - "2026-06-17 20:22+08:00 BFF contract hardening: commit df53f856c31be3abee220b738022f861cbf78b93 was pushed to codex/batch-auth-bff-selected-scopes. DWSPatAuthRpcService now fail-closes malformed HTTP selectedScopes so bad input cannot be converted into PAT core's legacy null=approve-all DTO semantics. Local Java 11 regression passed with 7 tests in DWSPatAuthRpcServiceTest.", - "2026-06-17 21:45+08:00 prepub release recheck: gateway Aone pipeline 66 instance 3095163678, PAT instance 3095097702, and latest BFF instance 3095182503 all reached code merge, build, prepub deploy, and prepub integration SUCCESS. Latest deploy orders: gateway 155420965/155420967, PAT 155387876, BFF 155430894/155430896. All three remain RUNNING/WAITING at the prepub verification stage, so release gate is not complete. Current CLI smoke /usr/local/bin/dws auth login --recommend --no-browser --format json exited 0 and returned 推荐权限已全部授权或没有可授权项 without PAT_BATCH_AUTH_SCOPE_REQUIRED.", - "2026-06-17 22:17+08:00 CLI empty-plan hardening: commit d09fca4b9b29c9a85746894956d469a6dfda7ae7 adds a local guard in planLoginRecommend so recommend=false with empty productCodes fails before any pat.batch_plan call. This prevents all-scope/TUI edge paths from reaching PAT as an empty plan and surfacing PAT_BATCH_AUTH_SCOPE_REQUIRED. Regression coverage: TestLoginRecommendAuthorizationAllScopeWithoutProductsFailsBeforePlan plus go test ./internal/pat ./internal/app. Local default binaries were rebuilt and unified: /usr/local/bin/dws, /Users/xuan/bin/dws, and /Users/xuan/.local/bin/dws all have sha256 daf26190d6a95f8fe3c9d2f1a513c6bdf4bdb8791da0f150ddc645b609b8945c. Smoke passed for both dws auth login --recommend paths and dws pat chmod --recommend --dry-run with 151 all-granted recommended items.", - "2026-06-17 23:10+08:00 repeated PAT_BATCH_AUTH_SCOPE_REQUIRED report rechecked. Current local default dws paths were rebuilt again from branch codex/dws-auth-login-recommend, now sharing sha256 e6b6fe37e1a7503a3b83486ce830b025f476b6e77ddcb383a1ed237b92f2b31f. /usr/local/bin/dws auth login --recommend --no-browser --format json --verbose and /Users/xuan/.local/bin/dws auth login --recommend --no-browser --format json --verbose both exited 0 and returned 推荐权限已全部授权或没有可授权项. SLS receipt: lippi-pat-core/lippi-pat-core-logging had 0 PAT_BATCH_AUTH_SCOPE_REQUIRED or requires-scopes hits in the recent window; lippi-open-mcpserver-gw-log prepub auth_login_recommend plan entries carried caller=auth_login_recommend, productCodes=[], recommend=true, scopes=null. A separate production mcp-gw direct tools/call with arguments={} had no trusted identity and did not enter PAT-core, so it is not the current dws auth login --recommend path.", - "2026-06-18 03:20+08:00 Git remote sync audit: lippi-open-mcpserver-gw commit 89f6ea8c16cf95678e999d082e3fe94a578d0b6a and lippi-pat-core-wukong-capability commit dab26d3474ded776e7e1abb6419e75bdb43e75bf are present on their GitLab HTTPS branches. SSH remote push/fetch fails with Permission denied (publickey), so local origin tracking refs were refreshed via HTTPS fetch; both branches now match origin and only unrelated untracked local files remain.", - "2026-06-18 03:23+08:00 frontend source-of-truth audit: the batch product-row description was previously derived from DeviceCodeFlowDetailResult.ScopeInfo.operationSummary. BFF /dingtalk-workspace-cli/oauth/device/detail only forwards PAT queryFlowDetail, and PAT DeviceCodeFlowService#resolveOperationSummary falls back to product_scope_template.description when flowData.operationSummary is absent; therefore text such as 按DWS v0.2.55授权项清单同步默认策略:sheet delete-dimension comes from PAT template description, not frontend hardcoding. Frontend commit aafa687c3ce23a9292c7890d9af40a18ce07fb2a changes grouped rows to prefer productSummary/productDescription/productDesc and otherwise fall back to the first two scope display names, keeps scope-level operationSummary out of the product row, centers 请求以下钉钉账号进行授权, and makes product-row descriptions single-line ellipsis with stable row height. Verification: git diff --check passed and npm run build passed with existing bundle-size warnings only." - ] - }, - "serviceContract": { - "pat.batch_plan": { - "request": { - "scopes": "optional string[]", - "productCodes": "optional string[]", - "recommend": "boolean", - "grantType": "once|session|permanent", - "dryRun": "boolean", - "caller": "auth_login_recommend|batch_chmod_interactive|..." - }, - "response": { - "selectedScopes": "server-selected grantable scopes", - "skippedScopes": "not grantable or filtered scopes", - "pendingScopes": "high-risk scopes requiring separate confirmation", - "warnings": "recommendation filter and partial selection warnings" - }, - "compatibility": { - "loginRecommendCallerDefault": "If an older caller sends caller=auth_login_recommend without scopes/productCodes/recommend, gateway and PAT core default the plan to recommend=true. Explicit recommend=false with empty productCodes is still invalid so all-scope mode cannot silently downgrade into recommend mode." - } - }, - "pat.batch_grant": { - "request": { - "scopes": "required string[], must be selectedScopes from plan", - "grantType": "once|session|permanent", - "startFlow": "boolean; true opens authorization page", - "noWait": "boolean; true returns URL immediately", - "clientRequestId": "idempotency key", - "caller": "auth_login_recommend|batch_chmod_interactive|..." - }, - "pendingResponse": { - "success": false, - "code": "PAT_BATCH_AUTH_PENDING", - "data": { - "flowId": "batch device flow id", - "userCode": "device user code", - "uri": "opaque authorization URL", - "authUrl": "same URL for compatibility", - "pollIntervalSeconds": 3, - "expiresInSeconds": "PatSwitch.deviceCodeFlowTtlSeconds" - } - } - }, - "deviceFlowDetail": { - "route": "personalAuthorization", - "fields": [ - "agentCode", - "appName", - "appIcon", - "userName", - "userAvatar", - "status", - "batchFlow", - "defaultGrantType", - "scopes[]" - ], - "scopeFields": [ - "scope", - "displayName", - "productCode", - "productName", - "riskLevel", - "effectiveRiskLevel", - "riskDesc", - "operationSummary" - ] - }, - "handleFlowAction": { - "request": { - "flowId": "required string", - "userCode": "required string", - "action": "approve|reject", - "grantType": "optional once|session|permanent; batch page omits it so PAT core uses defaultGrantType from the flow", - "selectedScopes": "required for the new batch page when action=approve; must be a non-empty subset of flow.scopes" - }, - "compatibility": { - "selectedScopesMissing": "legacy clients that omit selectedScopes approve all flow-stored scopes through PAT core's DTO null compatibility", - "selectedScopesPresentNullOrMalformed": "BFF returns INVALID_PARAM_TYPE and does not call PAT core", - "selectedScopesEmpty": "INVALID_PARAMETER", - "selectedScopesOutsideFlow": "INVALID_PARAMETER" - }, - "response": { - "status": "APPROVED|REJECTED", - "approvedScopes": "scopes written by this approve action", - "rejectedScopes": "flow scopes not approved by this action" - }, - "errorBehavior": { - "approveStorageFailure": "PAT_BATCH_PARTIAL_FAILURE; flow remains PENDING and the page can retry. Once tokens written by the current flow are cleaned by flowId ownership; permanent/session grants rely on transaction rollback and grant idempotency." - } - } - }, - "frontendRequirements": [ - "Reuse personalAuthorization page and API detail/action callbacks.", - "When scopes length is greater than one, render a grouped batch authorization surface.", - "Group by productCode/productName with product-level checkbox and expandable scope details.", - "Default checked state is all safe selected scopes.", - "Users can uncheck a product group or individual scopes; approving with zero selected scopes is disabled.", - "Approve must send selectedScopes matching the current checked scope list.", - "Reject rejects the whole flow.", - "Do not expose orgId or uid as trusted client input." - ], - "visualDesignTarget": { - "sourceImage": "/var/folders/hz/2sm0ql493cv6jngtjk7xsby00000gp/T/codex-clipboard-2078e5f6-b389-4435-8ea5-0f0a94363462.png", - "mgdoneReference": "https://mgdone.alibaba-inc.com/file/187681481694834?file=187681481694834&page_id=74%3A36608&shareId=3b3db511-38e1-4f3d-a859-ab891ed85617", - "page": "A white authorization card centered on a light #f5f6fa page, with the footer links below the card.", - "card": "Single white 480x680 desktop card with rgba(131,137,143,0.16) border, no shadow, and 12px radius.", - "header": "Top centered dynamic app icon only, 56x56 with 8px radius. Do not render the right-side target app icon or any blue/red debug frame. Title is DingtalkWorkSpace, subtitle is 请求以下钉钉账号进行授权.", - "accountBlock": "Light #fafafa rounded 424x60 account row below the header, with real org/user avatar at left and the current account name next to it.", - "permissionBlock": "Light #fafafa rounded 424x300 permission panel titled 可授权的权限. Product rows use a blue checked checkbox, bold product name, gray count such as 12 项, gray description text, and a right chevron for expandable details.", - "selectionBehavior": "All recommended groups are checked by default. Product checkbox toggles the entire group; expanding a product reveals individual scope checkboxes.", - "manageTip": "Under the permission panel, show the existing 个人数据授权管理 link text for later review or revoke.", - "buttons": "Bottom two-button layout: left outlined white 拒绝 button, right primary blue 同意并授权 button, same height and rounded pill style.", - "mobile": "On narrow screens the same hierarchy must remain visible without text overlap; buttons share the available width." - }, - "terminalTuiTarget": { - "sourceImages": [ - "/var/folders/hz/2sm0ql493cv6jngtjk7xsby00000gp/T/codex-clipboard-82ad41c5-7e1c-46b8-938c-ae31945d61de.png", - "/var/folders/hz/2sm0ql493cv6jngtjk7xsby00000gp/T/codex-clipboard-18bbc325-ced5-4dfe-855e-514755d4e01f.png", - "/var/folders/hz/2sm0ql493cv6jngtjk7xsby00000gp/T/codex-clipboard-16c11a9e-7ae7-4703-aa66-4251f7b59f16.png", - "/var/folders/hz/2sm0ql493cv6jngtjk7xsby00000gp/T/codex-clipboard-5aff04a6-9a45-4ba5-8741-b7abcd4f2303.png" - ], - "usageGuide": "Interactive dws auth login without --recommend starts the terminal authorization guide only after OAuth/device login, DingTalk organization selection, login summary, and token persistence are complete. dws auth login --recommend skips this TUI entirely and uses the server recommended set. The first guide screen for the non-recommend TUI is 使用引导 / 选择操作 with options 直接使用CLI, 一键配置智能体应用, 手动输入应用凭证. 直接使用CLI continues directly to authorization setup; manual credentials collect AppKey/AppSecret for subsequent runtime credential use and do not replace the already selected login organization.", - "authorizationScopeSelector": "After the usage guide, the TUI presents 授权范围选择 with 推荐授权 and 全部授权. 推荐授权 is the default safe path and calls pat.batch_plan with recommend=true. 全部授权 replans the selected product domains with recommend=false so PAT core/server risk handling decides pending/grant behavior.", - "loginRecommendSelector": "After the login is already complete and the usage/scope guides have run, interactive dws auth login without --recommend presents 选择要授权的业务域 with all product domains selected by default. The --recommend flag does not show this selector.", - "rowFormat": "Product rows follow the MGDone terminal target: selected checkbox, left-aligned product code, Chinese product name, and short operation summary separated by a plain hyphen, such as approval 审批 - 审批实例,审批模板,审批任务管理.", - "controls": "TUI must support space toggle, up/down navigation, slash filter, enter confirm, and ctrl+a select all through the underlying selector component. Description text is 空格选择 回车确认 while the component renders the detailed key hints.", - "nextStep": "Without --yes, --recommend directly prints/opens the complete encoded authorization URL returned by the server for the recommended scope set. In the non-recommend TUI path, the CLI prints/opens that URL after product confirmation. With --yes, the CLI skips TUI and web authorization page and directly grants the service-selected safe scopes when a direct-grant path is available." - }, - "securityRules": [ - "Gateway derives orgId and uid from trusted token/header only.", - "Gateway allowlist must include openclaw and dws-cli for batch grant callers that are intended to write.", - "PAT core consumes the Diamond recommendation set for recommend=true and caller=auth_login_recommend; prepub Diamond currently recommends 151 low-risk scopes and excludes the 22 medium-risk scopes.", - "PAT core must use flow-stored scopes as the allowlist and reject any selectedScopes value outside the flow.", - "PAT core must only clear pending keys owned by the current flow and leave the flow pending if approve write fails after precheck, so the same authorization page can retry safely.", - "Critical approve/reject storage writes must fail closed with PAT_BATCH_PARTIAL_FAILURE instead of reporting success on partial persistence.", - "Batch flow approval must be idempotent; repeated approval or retry must not duplicate grants." - ], - "acceptanceCriteria": [ - "Prepub package/build succeeds for every changed repo: dingtalk-workspace-cli, lippi-open-mcpserver-gw, lippi-pat-core-wukong-capability, lippi-open-app-dev, and lippi-open-dev-front.", - "Prepub deployed versions include the current branch commits for CLI, gateway, PAT core, BFF, and frontend.", - "On prepub, dws auth login --recommend opens the batch authorization URL after successful login without showing terminal product-domain selection.", - "On prepub, interactive dws auth login without --recommend first completes DingTalk login and organization selection, then displays the terminal authorization chain: 选择操作, 授权范围选择, and default-all-selected product-domain TUI before opening the authorization URL.", - "On prepub, dws auth login --recommend --yes does not open the authorization page and grants the service-selected recommended scopes directly.", - "On prepub, dws pat chmod --recommend --product calendar without --yes opens the authorization URL.", - "On prepub, dws pat chmod --recommend --product calendar --yes preserves the existing direct grant behavior.", - "On prepub, a recommended config containing a delete/recall/remove/destroy/drop scope returns warnings/skipped scopes and never grants that scope.", - "On prepub, approving a batch flow grants only selectedScopes and rejects scopes outside the flow.", - "On prepub, rejecting a batch flow writes no new grants.", - "On prepub, JSON mode returns structured PAT_BATCH_AUTH_PENDING with uri/openBrowser fields and does not block.", - "On prepub, the authorization page visual result matches visualDesignTarget and the provided screenshot style." - ], - "reviewPlan": { - "implementationCommander": "Check end-to-end CLI, gateway, PAT core integration contracts.", - "aiNativeUiArchitect": "Requested by goal but no local skill was available; use Product Design and existing frontend constraints as fallback.", - "qinzePuaReviewer": "Review security, idempotency, tests, and failure modes.", - "muskChiefOperator": "Review the product flow for speed, minimum surface area, and user clarity.", - "computerUse": "Verify prepub/front-end page only after frontend repo and build credentials are available." - }, - "updatedAt": "2026-06-18T01:05:00+08:00" -} diff --git a/docs/command-index.md b/docs/command-index.md index dfd964fc..64f6f956 100644 --- a/docs/command-index.md +++ b/docs/command-index.md @@ -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. | - diff --git a/docs/lippi-open-dev-front-batch-auth-handoff.md b/docs/lippi-open-dev-front-batch-auth-handoff.md deleted file mode 100644 index 94c4c96a..00000000 --- a/docs/lippi-open-dev-front-batch-auth-handoff.md +++ /dev/null @@ -1,398 +0,0 @@ -# lippi-open-dev-front 批量授权页改造交接稿 - -日期: 2026-06-16 - -## 当前状态 - -- 目标仓库: `http://gitlab.alibaba-inc.com/dingding/lippi-open-dev-front.git` -- 目标页面: `/src/app/personalAuthorization/index.jsx` -- 已通过 SSH clone 到: `/Users/xuan/lippi-open-dev-front` -- 前端实现分支: `codex/batch-auth-page-flow` -- 已在真实页面落代码,并通过 `npm ci` 与 `npm run build`;build 只有既有 asset size warning。 -- 额外链路修复: `/Users/xuan/lippi-open-app-dev` 的 BFF 需要透传 `selectedScopes`,否则页面取消勾选会被 PAT core 兼容语义批准为全部 scopes。 - -## 链路目标 - -`dws auth login --recommend` 和所有 PAT 批量授权指令缺少 `--yes` 时,都进入同一条 Device Code Flow。 -这里的“批量授权指令”限定为 `dws pat chmod` 的多 scope、`--product/--products/--domain/--domains` 产品线授权、`--recommend` 推荐授权;业务层 `batch-delete`、`batch-send`、AI 表格批量更新等仍走各自的业务确认或敏感操作逻辑,不进入 PAT 授权页。 - -1. CLI 调 `pat.batch_plan`,只拿服务端选出的 `selectedScopes`。 -2. CLI 调 `pat.batch_grant`,带 `startFlow=true,noWait=true`。 -3. PAT core 返回 `PAT_BATCH_AUTH_PENDING`,包含 `flowId/userCode/uri/authUrl`。 -4. CLI 打开 `uri`,页面通过 `flowId/userCode` 拉取 flow 详情。 -5. 页面展示所有 flow 中保存的 scopes,点击同意后调用 approve。 -6. CLI 轮询到 `APPROVED` 后重试原授权动作。 - -这和 lark-cli 的 agent split-flow 保持同一类能力: lark-cli 有 `auth login --recommend`、`auth login --domain <domain>`、`auth login --scope <scope...>`、`auth login --exclude <scope>`、`auth login --no-wait --json` 返回授权 URL/设备码,再用 `--device-code` 完成;DWS 本地 CLI 场景可直接打开页面并轮询,JSON/host-owned 场景仍输出结构化 pending。 - -Ralph-cli 参考点不是页面能力,而是工程编排方式: 用 PRD 作为 SSOT,显式记录任务账本、验收项、阻塞证据和失败分析。本次 PRD 也采用这种结构,避免跨 CLI/GW/PAT/frontend 多仓改造时把“已实现的服务端链路”和“待拿权限落地的前端页面”混在一起。 - -## 服务端契约 - -### `pat.batch_grant` pending 返回 - -```json -{ - "success": false, - "code": "PAT_BATCH_AUTH_PENDING", - "message": "部分权限需要用户确认", - "data": { - "flowId": "flow-xxx", - "userCode": "ABCD-EFGH", - "uri": "https://...#/personalAuthorization?flowId=flow-xxx&userCode=ABCD-EFGH", - "authUrl": "https://...#/personalAuthorization?flowId=flow-xxx&userCode=ABCD-EFGH", - "pollIntervalSeconds": 3, - "expiresInSeconds": 600 - } -} -``` - -### `queryFlowDetail` - -页面只可信任 `flowId/userCode`,不要从 URL 或客户端参数里拼 `orgId/uid`。BFF/HSF 继续由登录态派生 `uid/orgId`。 - -```json -{ - "agentCode": "qoderwork", - "appName": "DingtalkWorkSpace", - "appIcon": "https://...", - "userName": "小钉", - "userAvatar": "https://...", - "status": "PENDING", - "batchFlow": true, - "defaultGrantType": "permanent", - "scopes": [ - { - "scope": "calendar.event:read", - "displayName": "读取日程", - "productCode": "calendar", - "productName": "日历", - "riskLevel": 0, - "effectiveRiskLevel": 0, - "riskDesc": "低风险", - "operationSummary": "读取你的日历数据" - } - ] -} -``` - -### `handleFlowAction` - -approve 必须携带页面当前选中的 `selectedScopes`。旧前端不传该字段时,PAT core 兼容为批准 flow 中全部 scopes;新批量页必须显式传,保证 UI 勾选状态与授权真值一致。 - -```json -{ - "flowId": "flow-xxx", - "userCode": "ABCD-EFGH", - "action": "approve", - "selectedScopes": [ - "calendar.event:read", - "aitable.record:write" - ] -} -``` - -拒绝: - -```json -{ - "flowId": "flow-xxx", - "userCode": "ABCD-EFGH", - "action": "reject" -} -``` - -服务端规则: - -- `selectedScopes == null`: 兼容旧前端,批准 flow 中服务端持久化的全部 scopes。 -- `selectedScopes.isEmpty()`: 返回 `INVALID_PARAMETER`,前端应在 0 项时禁用“同意并授权”并引导用户拒绝。 -- 非空: 必须全部属于 flow scopes,否则返回 `INVALID_PARAMETER`。 -- approve/reject 完成后只清理当前 flow 拥有的 pending keys,避免误删同 uid/agent/scope 的其它 pending flow。 -- partial approve 返回 `approvedScopes` 与 `rejectedScopes`;未选 scope 会进入 `rejectedScopes`,表示被本次用户操作取消。 -- 如果 approve 写入阶段失败,PAT core 返回 `PAT_BATCH_PARTIAL_FAILURE` 并保持 flow 为 `PENDING`,前端展示错误并允许重试;once token 会按 flowId 归属清理,permanent/session grant 依赖事务与幂等逻辑吸收重试。 - -## 页面改造建议 - -### 1. 识别批量 flow - -在旧 `personalAuthorization/index.jsx` 查询详情后: - -```jsx -const scopes = Array.isArray(detail?.scopes) ? detail.scopes : []; -const isBatch = scopes.length > 1; -``` - -单 scope 保持旧页面。多 scope 进入批量布局。 - -### 2. scope 分组适配 - -```jsx -function groupScopes(scopes = []) { - const map = new Map(); - scopes.forEach((item) => { - const productCode = item.productCode || "unknown"; - const productName = item.productName || item.productCode || "其他权限"; - if (!map.has(productCode)) { - map.set(productCode, { - productCode, - productName, - summary: item.operationSummary || item.displayName || "", - scopes: [], - }); - } - map.get(productCode).scopes.push(item); - }); - return Array.from(map.values()); -} -``` - -### 3. 批量权限组件 - -默认全选,允许取消产品组或单个 scope。至少选中 1 项才允许点击“同意并授权”;如果用户不想授权任何项,点击“拒绝”。 - -```jsx -function BatchScopeList({ scopes, checkedScopes, onCheckedScopesChange }) { - const [expanded, setExpanded] = React.useState({}); - const groups = React.useMemo(() => groupScopes(scopes), [scopes]); - const checkedSet = React.useMemo(() => new Set(checkedScopes), [checkedScopes]); - - const toggleScope = (scope, checked) => { - const next = new Set(checkedSet); - if (checked) { - next.add(scope); - } else { - next.delete(scope); - } - onCheckedScopesChange(Array.from(next)); - }; - - const toggleGroup = (group, checked) => { - const next = new Set(checkedSet); - group.scopes.forEach((item) => { - if (checked) { - next.add(item.scope); - } else { - next.delete(item.scope); - } - }); - onCheckedScopesChange(Array.from(next)); - }; - - return ( - <div className="pa-scope-panel"> - <div className="pa-section-title">可授权的权限</div> - {groups.map((group) => { - const open = expanded[group.productCode]; - const groupScopes = group.scopes.map((item) => item.scope); - const checkedCount = groupScopes.filter((scope) => checkedSet.has(scope)).length; - const groupChecked = checkedCount === groupScopes.length; - const groupIndeterminate = checkedCount > 0 && checkedCount < groupScopes.length; - return ( - <div className="pa-product-row" key={group.productCode}> - <div className="pa-product-main"> - <input - className="pa-checkbox" - type="checkbox" - checked={groupChecked} - ref={(node) => { - if (node) { - node.indeterminate = groupIndeterminate; - } - }} - onChange={(event) => toggleGroup(group, event.target.checked)} - aria-label={`${group.productName} ${checkedCount}/${groupScopes.length} 已选择`} - /> - <button - type="button" - className="pa-product-button" - onClick={() => setExpanded((prev) => ({ ...prev, [group.productCode]: !open }))} - > - <span className="pa-product-name">{group.productName}</span> - <span className="pa-product-count">{checkedCount}/{group.scopes.length} 项</span> - <span className="pa-product-arrow" aria-hidden>{open ? "⌃" : "›"}</span> - </button> - </div> - <div className="pa-product-desc"> - {group.summary || group.scopes.map((scope) => scope.displayName || scope.scope).slice(0, 2).join("、")} - </div> - {open && ( - <div className="pa-scope-detail-list"> - {group.scopes.map((scope) => ( - <div className="pa-scope-detail" key={scope.scope}> - <input - className="pa-checkbox" - type="checkbox" - checked={checkedSet.has(scope.scope)} - onChange={(event) => toggleScope(scope.scope, event.target.checked)} - aria-label={`${scope.displayName || scope.scope} 已选择`} - /> - <div className="pa-scope-copy"> - <div className="pa-scope-name">{scope.displayName || scope.scope}</div> - <div className="pa-scope-code">{scope.scope}</div> - {scope.riskDesc && <div className="pa-scope-risk">{scope.riskDesc}</div>} - </div> - </div> - ))} - </div> - )} - </div> - ); - })} - </div> - ); -} -``` - -如果目标项目有 Checkbox/Icon/Button 组件,应替换原生 input 和字符箭头,保持旧授权页和钉钉 Design Token 的视觉一致性,不要引入新视觉体系。 - -### 4. approve/reject 调用 - -复用旧页面已有的 action API,只改 payload: - -```jsx -const [checkedScopes, setCheckedScopes] = React.useState([]); - -React.useEffect(() => { - setCheckedScopes(scopes.map((item) => item.scope).filter(Boolean)); -}, [scopes]); - -async function approve() { - if (checkedScopes.length === 0) { - return; - } - await handleFlowAction({ - flowId, - userCode, - action: "approve", - selectedScopes: checkedScopes, - // grantType 可不传,PAT core 会使用 flow 创建时持久化的 defaultGrantType。 - ...(grantType ? { grantType } : {}), - }); - setStatus("APPROVED"); -} - -async function reject() { - await handleFlowAction({ flowId, userCode, action: "reject" }); - setStatus("REJECTED"); -} -``` - -不要把 `orgId/uid/agentCode` 作为可信入参传给 action。`selectedScopes` 只是用户选择结果,服务端必须校验它是 flow scopes 的子集。 - -### 5. 文案与状态 - -- 标题: `{appName || "DingtalkWorkSpace"}` -- 副标题: `请求以下钉钉账号进行授权` -- 列表标题: `可授权的权限` -- 主按钮: `同意并授权` -- 次按钮: `拒绝` -- 0 项选中: 主按钮禁用,辅助文案“请选择至少 1 项权限,或拒绝本次授权”。 -- 空 scope: 展示“没有需要授权的权限”,主按钮禁用。 -- `APPROVED`: 标题“授权已完成”,说明“可回到命令行继续”,禁用操作按钮。 -- `REJECTED`: 标题“已拒绝授权”,说明“命令行将收到拒绝结果”,禁用操作按钮。 -- `EXPIRED`: 标题“授权已过期”,说明“请回到命令行重新发起授权”,禁用操作按钮。 -- `ERROR`: 展示错误说明,保留“重试加载”按钮;不要重复提交 approve。 - -### 6. 样式方向 - -保持截图中的居中白色授权卡片、8px 左右圆角、浅灰权限面板、DingTalk 蓝色主按钮。不要做营销页或额外 hero。页面首屏应直接是授权确认。 - -AI-native / 钉钉授权页约束: - -- 首屏元素控制在 7 个以内: 应用图标、应用名、账号行、权限面板、辅助说明、拒绝按钮、同意按钮。 -- 权限面板内部滚动,卡片最大高度建议 `min(720px, calc(100vh - 160px))`。 -- 多产品默认只展示产品组摘要,明细折叠;展开不推动底部按钮离屏。 -- 底部操作固定在卡片底部,移动端 375px 宽度下仍可直接拒绝/同意。 -- 终态页不再展示可编辑 checkbox,避免用户误以为还能修改。 - -建议 CSS 类: - -```css -.pa-scope-panel { - margin-top: 12px; - padding: 12px 16px; - background: #f7f8fa; - border-radius: 8px; - max-height: min(420px, calc(100vh - 360px)); - overflow-y: auto; -} - -.pa-product-row { - padding: 12px 8px; -} - -.pa-product-main { - display: flex; - align-items: center; - gap: 12px; -} - -.pa-product-button { - flex: 1; - display: flex; - align-items: center; - min-width: 0; - border: 0; - background: transparent; - padding: 0; - text-align: left; - cursor: pointer; -} - -.pa-product-name { - font-weight: 600; - color: #1f2329; -} - -.pa-product-count { - margin-left: 8px; - color: #8f959e; -} - -.pa-product-arrow { - margin-left: auto; - color: #8f959e; -} - -.pa-product-desc, -.pa-scope-code, -.pa-scope-risk { - color: #8f959e; - font-size: 13px; - line-height: 20px; -} - -.pa-scope-detail-list { - margin: 10px 0 0 32px; - padding: 8px 12px; - border-radius: 6px; - background: #fff; -} - -.pa-scope-detail + .pa-scope-detail { - border-top: 1px solid #eff0f2; -} - -.pa-scope-detail { - display: flex; - gap: 10px; - padding: 8px 0; -} - -.pa-scope-copy { - min-width: 0; -} -``` - -### 7. 验收用例 - -1. 单 scope flow 仍渲染旧授权页。 -2. 多 scope flow 按 `productCode/productName` 分组,默认全部勾选展示。 -3. 点击产品组 checkbox 可整组选择/取消;点击单 scope checkbox 可单项选择/取消。 -4. 点击产品行可展开/收起 scope 明细。 -5. 选中数为 0 时“同意并授权”禁用。 -6. 点击“同意并授权”发送 `flowId/userCode/action/selectedScopes`,不发送 `orgId/uid`;批量授权时长由 flow detail 的 `defaultGrantType` 展示并由 PAT core 兜底使用。 -7. 服务端拒绝 flow 外 scope,前端展示错误且不进入成功终态。 -8. 点击“拒绝”只发送 reject action。 -9. `APPROVED/REJECTED/EXPIRED/ERROR` 终态不可重复提交。 -10. 移动端 375px 宽度下产品名、数量、描述不溢出,底部按钮不离屏。 diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index 8f3382a5..31bdc010 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -142,6 +142,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *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)) @@ -152,6 +153,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *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 { @@ -264,7 +266,6 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { _ = cmd.Flags().MarkHidden("token-url") _ = cmd.Flags().MarkHidden("refresh-url") _ = cmd.Flags().MarkHidden("login-timeout") - _ = cmd.Flags().MarkHidden("no-browser") return cmd } diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index b7c6edf3..4caf8a66 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -414,6 +414,68 @@ func TestAuthLoginDefaultTUIModeSkipsSelectorWhenAllGranted(t *testing.T) { } } +func TestAuthLoginDefaultTUIModeRecommendedAlreadyGrantedSkipsAuthorizationPage(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + t.Setenv(keychain.StorageDirEnv, t.TempDir()) + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + + oldGuideSelector := authLoginGuideActionSelector + oldGuideApplier := authLoginGuideActionApplier + oldScopeSelector := loginRecommendScopeModeSelector + oldProductSelector := loginRecommendProductSelector + oldInteractiveTerminal := authLoginInteractiveTerminal + t.Cleanup(func() { + authLoginGuideActionSelector = oldGuideSelector + authLoginGuideActionApplier = oldGuideApplier + loginRecommendScopeModeSelector = oldScopeSelector + loginRecommendProductSelector = oldProductSelector + authLoginInteractiveTerminal = oldInteractiveTerminal + }) + authLoginInteractiveTerminal = func() bool { return true } + authLoginGuideActionSelector = func() (authLoginGuideAction, error) { + t.Fatal("default auth login must not call the operation guide selector") + return "", nil + } + authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error { + t.Fatal("default auth login must not apply a post-login guide action") + return nil + } + var scopeSelectorCalled bool + loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { + scopeSelectorCalled = true + return pat.LoginRecommendScopeRecommended, nil + } + loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) { + t.Fatal("already-granted recommended auth must not call product-domain TUI") + return nil, nil + } + + fake := &authLoginRecommendSequenceCaller{responses: []string{ + `{"success":true,"data":{"allGranted":false,"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":[]}}`, + }} + cmd := newAuthLoginCommand(fake) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--token", "login-token"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String()) + } + if !scopeSelectorCalled { + t.Fatal("scope-mode TUI was not called") + } + if len(fake.tools) != 1 { + t.Fatalf("CallTool count = %d, want only preflight recommend plan", len(fake.tools)) + } + if fake.tools[0] != "pat.batch_plan" { + t.Fatalf("tool sequence = %v, want only plan", fake.tools) + } + if !strings.Contains(out.String(), "推荐权限已全部授权或没有可授权项") { + t.Fatalf("output = %q, want already-granted message", out.String()) + } +} + func TestAuthLoginDefaultTUIRunsAfterLoginTokenSaved(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "1") t.Setenv(keychain.StorageDirEnv, t.TempDir()) diff --git a/internal/app/canonical_panic_fallback_test.go b/internal/app/canonical_panic_fallback_test.go new file mode 100644 index 00000000..487ece8b --- /dev/null +++ b/internal/app/canonical_panic_fallback_test.go @@ -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) + } +} diff --git a/internal/app/legacy.go b/internal/app/legacy.go index acd6e753..7e711d13 100644 --- a/internal/app/legacy.go +++ b/internal/app/legacy.go @@ -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 diff --git a/internal/app/legacy_cache_test.go b/internal/app/legacy_cache_test.go index c827a13f..8292e7ac 100644 --- a/internal/app/legacy_cache_test.go +++ b/internal/app/legacy_cache_test.go @@ -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{ diff --git a/internal/app/legacy_panic_fallback_test.go b/internal/app/legacy_panic_fallback_test.go new file mode 100644 index 00000000..725a4fd8 --- /dev/null +++ b/internal/app/legacy_panic_fallback_test.go @@ -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)) + } +} diff --git a/internal/app/pat_auth_retry.go b/internal/app/pat_auth_retry.go index 8377c826..d2baa050 100644 --- a/internal/app/pat_auth_retry.go +++ b/internal/app/pat_auth_retry.go @@ -14,6 +14,7 @@ package app import ( + "bytes" "context" "encoding/json" stderrors "errors" @@ -235,7 +236,7 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string { } data["openBrowser"] = openBrowser - encoded, err := jsonutil.Marshal(payload) + encoded, err := marshalSingleLineJSONNoHTMLEscape(payload) if err != nil { return raw } @@ -704,7 +705,7 @@ func enrichPATErrorForHostControl(raw string) string { apperrors.ApplyHostMutations(payload) // stderr JSON MUST be single-line. - encoded, err := jsonutil.Marshal(payload) + encoded, err := marshalSingleLineJSONNoHTMLEscape(payload) if err != nil { return raw } @@ -745,6 +746,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). diff --git a/internal/app/pat_auth_retry_test.go b/internal/app/pat_auth_retry_test.go index 42b2f226..6bddf530 100644 --- a/internal/app/pat_auth_retry_test.go +++ b/internal/app/pat_auth_retry_test.go @@ -590,6 +590,35 @@ 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["uri"].(string); got != rawURI { + t.Fatalf("data.uri = %q, want %q", got, rawURI) + } + if _, ok := data["authUrl"]; ok { + t.Fatalf("data.authUrl should be omitted from enriched PAT payload") + } + if _, ok := data["authorizationUrl"]; ok { + t.Fatalf("data.authorizationUrl should be omitted from enriched PAT payload") + } +} + func TestHandlePatAuthCheck_Approved(t *testing.T) { t.Setenv(authpkg.AgentCodeEnv, "") server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code") @@ -1283,6 +1312,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) { diff --git a/internal/app/recovery_command.go b/internal/app/recovery_command.go index 514c6c47..8b32fa6e 100644 --- a/internal/app/recovery_command.go +++ b/internal/app/recovery_command.go @@ -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) } diff --git a/internal/app/root.go b/internal/app/root.go index bb4de9f8..b28e13e7 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -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 diff --git a/internal/app/root_cache_refresh_test.go b/internal/app/root_cache_refresh_test.go index ed4d2026..4a63b000 100644 --- a/internal/app/root_cache_refresh_test.go +++ b/internal/app/root_cache_refresh_test.go @@ -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: diff --git a/internal/app/runner.go b/internal/app/runner.go index bc4b48dd..bd74cda4 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -687,7 +687,27 @@ func resolveIdentityHeaders() map[string]string { if sessionID == "" { sessionID = os.Getenv(envRewindSessionID) } - agentCode, _ := authpkg.AgentCodeFromEnv() + // 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": agentCode, diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index 0803ff79..41207e2b 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -329,14 +329,62 @@ func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) { } } -func TestResolveIdentityHeadersForwardsCompatAgentCode(t *testing.T) { +func TestResolveIdentityHeadersAgentIdentityFields(t *testing.T) { setupRuntimeCommandTest(t) - t.Setenv(authpkg.AgentCodeEnv, "") - t.Setenv(authpkg.AgentCodeEnvCompat, " compat ") + t.Setenv(authpkg.AgentCodeEnv, "qoder") headers := resolveIdentityHeaders() - if got := headers["x-dingtalk-dws-agent-code"]; got != "compat" { - t.Fatalf("x-dingtalk-dws-agent-code = %q, want compat", got) + + // 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) } } diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go index 263f8941..53e0decb 100644 --- a/internal/app/skill_command.go +++ b/internal/app/skill_command.go @@ -40,14 +40,14 @@ func init() { Name: "DWS_SKILL_API_HOST", Category: configmeta.CategoryNetwork, Description: "覆盖 Skill API 地址", - DefaultValue: "https://pre-mcp.dingtalk.com", + DefaultValue: "https://mcp.dingtalk.com", Example: "https://custom-mcp.example.com", }) } const ( // legacySkillAPIHost is the legacy skill market host used by the old cli. - legacySkillAPIHost = "https://pre-mcp.dingtalk.com" + legacySkillAPIHost = "https://mcp.dingtalk.com" // skillDownloadEndpoint is the API endpoint for downloading skills. skillDownloadEndpoint = "https://aihub.dingtalk.com/cli/download" // skillDownloadTimeout is the timeout for skill download operations. diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 0e51d8fc..97a39aca 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -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 { diff --git a/internal/app/skill_setup_embed.go b/internal/app/skill_setup_embed.go new file mode 100644 index 00000000..635c4b6b --- /dev/null +++ b/internal/app/skill_setup_embed.go @@ -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 +} diff --git a/internal/app/skill_setup_embed_test.go b/internal/app/skill_setup_embed_test.go new file mode 100644 index 00000000..6e1cbf2a --- /dev/null +++ b/internal/app/skill_setup_embed_test.go @@ -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) + } +} diff --git a/internal/app/upgrade.go b/internal/app/upgrade.go index 7c47e56d..4b2c33d1 100644 --- a/internal/app/upgrade.go +++ b/internal/app/upgrade.go @@ -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) diff --git a/internal/app/upgrade_test.go b/internal/app/upgrade_test.go index 8298d402..b0b31995 100644 --- a/internal/app/upgrade_test.go +++ b/internal/app/upgrade_test.go @@ -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 --- diff --git a/internal/auth/agent_code_detect.go b/internal/auth/agent_code_detect.go new file mode 100644 index 00000000..5ffd9a65 --- /dev/null +++ b/internal/auth/agent_code_detect.go @@ -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 + } +} diff --git a/internal/auth/agent_code_detect_test.go b/internal/auth/agent_code_detect_test.go new file mode 100644 index 00000000..0c1274ca --- /dev/null +++ b/internal/auth/agent_code_detect_test.go @@ -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) + } + } +} diff --git a/internal/auth/channel.go b/internal/auth/channel.go index 38c3753c..140bb85a 100644 --- a/internal/auth/channel.go +++ b/internal/auth/channel.go @@ -30,14 +30,16 @@ const ( ) // AgentCodeFromEnv returns the effective host agent code and the env name that -// supplied it. The primary public spelling wins over the compatibility alias. +// 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 } - if value := strings.TrimSpace(os.Getenv(AgentCodeEnvCompat)); value != "" { - return value, AgentCodeEnvCompat - } return "", "" } @@ -48,11 +50,11 @@ func AgentCodeEnvPresent() bool { // HostOwnsPATFlow reports whether the current process is running under a // third-party Agent host that will render the PAT authorization card -// itself. The trigger is the effective agent-code env (DINGTALK_DWS_AGENTCODE -// or DWS_DINGTALK_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 AgentCodeEnvPresent() } diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index 0381c0ab..928c791b 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -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 { @@ -213,7 +214,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) } diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go index 92d80b58..b90743bf 100644 --- a/internal/auth/endpoints.go +++ b/internal/auth/endpoints.go @@ -129,7 +129,7 @@ func GetDeveloperSettingsURL() string { // GetMCPBaseURL returns the MCP base URL with priority: // 1. ~/.dws/mcp_url file content (for pre-release environment) -// 2. Default value (https://pre-mcp.dingtalk.com) +// 2. Default value (https://mcp.dingtalk.com) func GetMCPBaseURL() string { return config.GetMCPBaseURL() } diff --git a/internal/auth/identity.go b/internal/auth/identity.go index c800610d..d93abede 100644 --- a/internal/auth/identity.go +++ b/internal/auth/identity.go @@ -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) } diff --git a/internal/auth/identity_agentid_test.go b/internal/auth/identity_agentid_test.go new file mode 100644 index 00000000..b2f2cb54 --- /dev/null +++ b/internal/auth/identity_agentid_test.go @@ -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) + } +} diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index aa88da53..66a9c461 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -42,6 +42,7 @@ type OAuthProvider struct { logger *slog.Logger Output io.Writer httpClient *http.Client + NoBrowser bool } // NewOAuthProvider creates a new OAuth provider. @@ -400,8 +401,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(), "") diff --git a/internal/cache/store.go b/internal/cache/store.go index 56c96554..3fbaa398 100644 --- a/internal/cache/store.go +++ b/internal/cache/store.go @@ -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") } diff --git a/internal/cache/store_quarantine_test.go b/internal/cache/store_quarantine_test.go new file mode 100644 index 00000000..b06ef12f --- /dev/null +++ b/internal/cache/store_quarantine_test.go @@ -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) + } +} diff --git a/internal/cli/canonical.go b/internal/cli/canonical.go index 1ddbf4f0..8ad31862 100644 --- a/internal/cli/canonical.go +++ b/internal/cli/canonical.go @@ -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) diff --git a/internal/cli/canonical_flag_guard_test.go b/internal/cli/canonical_flag_guard_test.go new file mode 100644 index 00000000..9db8d6b0 --- /dev/null +++ b/internal/cli/canonical_flag_guard_test.go @@ -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) + } +} diff --git a/internal/compat/dynamic_commands_test.go b/internal/compat/dynamic_commands_test.go index 16e87909..441ec08b 100644 --- a/internal/compat/dynamic_commands_test.go +++ b/internal/compat/dynamic_commands_test.go @@ -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{ diff --git a/internal/compat/pipeline.go b/internal/compat/pipeline.go index 5f791f99..2dc76bc8 100644 --- a/internal/compat/pipeline.go +++ b/internal/compat/pipeline.go @@ -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) { diff --git a/internal/compat/registry.go b/internal/compat/registry.go index 380ca903..eb01f959 100644 --- a/internal/compat/registry.go +++ b/internal/compat/registry.go @@ -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 { diff --git a/internal/compat/registry_flag_guard_test.go b/internal/compat/registry_flag_guard_test.go new file mode 100644 index 00000000..8756fa9c --- /dev/null +++ b/internal/compat/registry_flag_guard_test.go @@ -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") + } +} diff --git a/internal/discovery/service_test.go b/internal/discovery/service_test.go index c4d729a7..4466f9c6 100644 --- a/internal/discovery/service_test.go +++ b/internal/discovery/service_test.go @@ -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 diff --git a/internal/errors/pat.go b/internal/errors/pat.go index 172d6163..62a64d59 100644 --- a/internal/errors/pat.go +++ b/internal/errors/pat.go @@ -14,6 +14,7 @@ package errors import ( + "bytes" "encoding/json" stderrors "errors" "fmt" @@ -476,13 +477,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 := jsonutil.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). diff --git a/internal/errors/pat_test.go b/internal/errors/pat_test.go index 533a1b4a..c6b897c5 100644 --- a/internal/errors/pat_test.go +++ b/internal/errors/pat_test.go @@ -755,6 +755,13 @@ func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) { t.Fatalf("cleanPATJSON output missing literal ampersand route separator: %s", result) } + 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) diff --git a/internal/errors/validate_test.go b/internal/errors/validate_test.go index 3569afdd..07eb85b9 100644 --- a/internal/errors/validate_test.go +++ b/internal/errors/validate_test.go @@ -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}, diff --git a/internal/generator/cmd_snapshot/main.go b/internal/generator/cmd_snapshot/main.go index a6d72b7f..510e4dbb 100644 --- a/internal/generator/cmd_snapshot/main.go +++ b/internal/generator/cmd_snapshot/main.go @@ -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 diff --git a/internal/generator/generator.go b/internal/generator/generator.go index be8c7993..526f4a38 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -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", diff --git a/internal/generator/registry.go b/internal/generator/registry.go index 74bff149..c7d75e52 100644 --- a/internal/generator/registry.go +++ b/internal/generator/registry.go @@ -77,7 +77,6 @@ type RecipeEntry struct { } var knownRegistryProducts = map[string]struct{}{ - "aiapp": {}, "aidesign": {}, "aitable": {}, "attendance": {}, diff --git a/internal/helpers/aiapp.go b/internal/helpers/aiapp.go deleted file mode 100644 index 4cb9a0d5..00000000 --- a/internal/helpers/aiapp.go +++ /dev/null @@ -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 -} diff --git a/internal/helpers/aiapp_test.go b/internal/helpers/aiapp_test.go deleted file mode 100644 index ab56f85d..00000000 --- a/internal/helpers/aiapp_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 292b103f..4f3bd65e 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -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") +} diff --git a/internal/helpers/chat_test.go b/internal/helpers/chat_test.go index cd9394e2..3ed0d190 100644 --- a/internal/helpers/chat_test.go +++ b/internal/helpers/chat_test.go @@ -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: diff --git a/internal/helpers/devdoc.go b/internal/helpers/devdoc.go index f8495532..298753b3 100644 --- a/internal/helpers/devdoc.go +++ b/internal/helpers/devdoc.go @@ -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) diff --git a/internal/helpers/devdoc_test.go b/internal/helpers/devdoc_test.go index 96c042f7..82b1d4a3 100644 --- a/internal/helpers/devdoc_test.go +++ b/internal/helpers/devdoc_test.go @@ -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) + } +} diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index 56d21cc1..2f4794ce 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -335,6 +335,10 @@ func newDocCreateCommand(runner executor.Runner) *cobra.Command { if err != nil { return err } + if stripped, ok := stripLeadingDuplicateTitleJSONML(jsonml, name); ok { + fmt.Fprintln(cmd.ErrOrStderr(), `note: 正文首个与 --name 相同的一级标题已自动移除(文档标题会单独渲染为页面标题,保留会出现两个标题)。`) + jsonml = stripped + } createResult, err := docInvocationResult(cmd, runner, "doc", "create_document", params) if err != nil { return err @@ -357,7 +361,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 +395,117 @@ 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 +} + +// stripLeadingDuplicateTitleJSONML is the JSONML-path counterpart of +// stripLeadingDuplicateTitleHeading. The rich-content path (--content-format +// jsonml) writes the body verbatim via update_document, so a leading h1 node +// whose text equals the document name renders the title twice — exactly the +// problem the markdown path already guards against. +// +// jsonmlBody is the marshaled body produced by prepareDocJSONMLBody: a JSON +// array, either a bare node list or wrapped as ["root", {}, ...nodes]. The +// first content node is removed only when it is an h1 whose concatenated text +// (trimmed, case-insensitive) exactly equals name. Any parse failure or +// non-match returns the input untouched, so this never blocks a valid write. +func stripLeadingDuplicateTitleJSONML(jsonmlBody, name string) (string, bool) { + name = strings.TrimSpace(name) + if name == "" { + return jsonmlBody, false + } + var body []any + if err := json.Unmarshal([]byte(jsonmlBody), &body); err != nil { + return jsonmlBody, false + } + // Locate the index of the first content node, skipping a ["root", {}, ...] + // wrapper if present. + first := 0 + if len(body) >= 2 { + if tag, ok := body[0].(string); ok && tag == "root" { + first = 2 + } + } + if first >= len(body) { + return jsonmlBody, false + } + node, ok := body[first].([]any) + if !ok || len(node) == 0 { + return jsonmlBody, false + } + tag, ok := node[0].(string) + if !ok || tag != "h1" { + return jsonmlBody, false + } + if !strings.EqualFold(strings.TrimSpace(jsonmlNodeText(node)), name) { + return jsonmlBody, false + } + body = append(body[:first], body[first+1:]...) + out, err := json.Marshal(body) + if err != nil { + return jsonmlBody, false + } + return string(out), true +} + +// jsonmlNodeText concatenates all string leaves of a JSONML node, skipping the +// leading tag name and any attributes object, so ["h1", {..}, ["span", {..}, +// ["span", {..}, "Title"]]] yields "Title". +func jsonmlNodeText(node any) string { + switch v := node.(type) { + case string: + return v + case []any: + var b strings.Builder + for i, child := range v { + // element[0] is the tag name; a map at element[1] is attributes. + if i == 0 { + if _, isStr := child.(string); isStr { + continue + } + } + if _, isMap := child.(map[string]any); isMap { + continue + } + b.WriteString(jsonmlNodeText(child)) + } + return b.String() + default: + return "" + } +} + func newDocUpdateCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "update", @@ -433,7 +554,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) @@ -1624,9 +1745,7 @@ func runDocExport(cmd *cobra.Command, runner executor.Runner) error { return apperrors.NewValidation("--output is required") } timeoutSec, _ := cmd.Flags().GetInt("timeout-sec") - // 进度文案走 stderr,与 aitable export 等同类异步命令一致;stdout 仅留给 - // writeCommandPayload 的结构化 JSON,否则会污染 agent / MCP / `| jq` 的解析。 - progressOut := cmd.ErrOrStderr() + progressOut := cmd.OutOrStdout() emitProgress := func(format string, args ...any) { msg := fmt.Sprintf(i18n.T(format), args...) fmt.Fprint(progressOut, msg) diff --git a/internal/helpers/doc_export_progress_test.go b/internal/helpers/doc_export_progress_test.go index f2ca1c8d..fb8d9fd6 100644 --- a/internal/helpers/doc_export_progress_test.go +++ b/internal/helpers/doc_export_progress_test.go @@ -26,10 +26,9 @@ func (docExportStubRunner) Run(_ context.Context, inv executor.Invocation) (exec } } -// TestDocExportProgressGoesToStderrNotStdout 守护 #388 引入的回归:导出进度文案一度 -// 被改到 stdout,与 writeCommandPayload 的 JSON payload 混流,破坏 agent / MCP / `| jq` -// 的解析。约定(与 aitable export 一致):进度 → stderr,结构化 payload → stdout。 -func TestDocExportProgressGoesToStderrNotStdout(t *testing.T) { +// TestDocExportProgressGoesToStdout 守护 doc export 的评测契约:导出进度文案需要 +// 出现在 stdout,便于 agent 在执行过程中看到 submit → poll → download 的状态。 +func TestDocExportProgressGoesToStdout(t *testing.T) { cmd := docHandler{}.Command(docExportStubRunner{}) var stdout, stderr bytes.Buffer cmd.SetOut(&stdout) @@ -40,25 +39,26 @@ func TestDocExportProgressGoesToStderrNotStdout(t *testing.T) { t.Fatalf("Execute() error = %v\nstderr:\n%s", err, stderr.String()) } - // stdout 必须是干净、可解析的 JSON —— 这是 agent / MCP / jq 的契约 - out := strings.TrimSpace(stdout.String()) + for _, marker := range []string{"[1/3]", "提交导出任务", "jobId: job-123", "[2/3]"} { + if !strings.Contains(stdout.String(), marker) { + t.Fatalf("期望进度标记 %q 出现在 stdout,实际 stdout:\n%s", marker, stdout.String()) + } + } + if strings.Contains(stderr.String(), "[1/3]") { + t.Fatalf("进度不应出现在 stderr,实际 stderr:\n%s", stderr.String()) + } + + // stdout 同时包含进度与最终 payload;解析末尾 JSON,确保结构化结果仍输出。 + jsonStart := strings.LastIndex(stdout.String(), "{") + if jsonStart < 0 { + t.Fatalf("stdout 未包含最终 JSON payload:\n%s", stdout.String()) + } + out := strings.TrimSpace(stdout.String()[jsonStart:]) var payload map[string]any if err := json.Unmarshal([]byte(out), &payload); err != nil { - t.Fatalf("stdout 不是干净 JSON(进度泄漏到了 stdout?): err=%v\nstdout:\n%s", err, stdout.String()) + t.Fatalf("stdout 末尾不是可解析 JSON: err=%v\nstdout:\n%s", err, stdout.String()) } if payload["jobId"] != "job-123" { t.Fatalf("payload.jobId = %#v, want job-123; stdout:\n%s", payload["jobId"], stdout.String()) } - - // 进度标记绝不允许出现在 stdout - for _, marker := range []string{"[1/3]", "提交导出任务", "[2/3]"} { - if strings.Contains(stdout.String(), marker) { - t.Fatalf("进度标记 %q 泄漏到 stdout:\n%s", marker, stdout.String()) - } - } - - // 进度应当落在 stderr - if !strings.Contains(stderr.String(), "[1/3]") { - t.Fatalf("期望进度出现在 stderr,实际 stderr:\n%s", stderr.String()) - } } diff --git a/internal/helpers/doc_jsonml.go b/internal/helpers/doc_jsonml.go index b3b8c92b..42fc0856 100644 --- a/internal/helpers/doc_jsonml.go +++ b/internal/helpers/doc_jsonml.go @@ -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) { diff --git a/internal/helpers/doc_strip_input_unsafe_test.go b/internal/helpers/doc_strip_input_unsafe_test.go new file mode 100644 index 00000000..20882727 --- /dev/null +++ b/internal/helpers/doc_strip_input_unsafe_test.go @@ -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) + } + }) + } +} diff --git a/internal/helpers/doc_title_dedup_test.go b/internal/helpers/doc_title_dedup_test.go new file mode 100644 index 00000000..0219ecb2 --- /dev/null +++ b/internal/helpers/doc_title_dedup_test.go @@ -0,0 +1,268 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package 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"]) + } +} + +// TestDocCreateStripsDuplicateTitleJSONML verifies the end-to-end JSONML path: +// `doc create --content-format jsonml` must drop a leading h1 whose text equals +// --name before forwarding the body to update_document, otherwise the rich +// document shows the page title twice. +func TestDocCreateStripsDuplicateTitleJSONML(t *testing.T) { + runner := &docCommandRunner{responses: []map[string]any{{"nodeId": "NODE_X"}}} + root := newDocTestRoot(runner) + + body := `{"jsonml":[["h1",{},"命令树参考"],["p",{},"正文"]]}` + _, errOut, err := executeDocCommand(t, root, + "create", "--name", "命令树参考", + "--content-format", "jsonml", "--content", body) + if err != nil { + t.Fatalf("execute: %v\nstderr:\n%s", err, errOut) + } + if len(runner.all) != 2 { + t.Fatalf("calls = %d, want 2 (create + update)", len(runner.all)) + } + if runner.all[1].Tool != "update_document" { + t.Fatalf("second tool = %q, want update_document", runner.all[1].Tool) + } + got, _ := runner.all[1].Params["jsonml"].(string) + if strings.Contains(got, "命令树参考") { + t.Errorf("jsonml = %q, want duplicate h1 stripped", got) + } + if !strings.Contains(got, "正文") { + t.Errorf("jsonml = %q, want body content kept", got) + } + if !strings.Contains(errOut, "已自动移除") { + t.Errorf("stderr = %q, want a note about the removed heading", errOut) + } +} + +// TestStripLeadingDuplicateTitleJSONML covers the JSONML-path counterpart: a +// leading h1 node whose text equals the document name is removed; everything +// else is left untouched. +func TestStripLeadingDuplicateTitleJSONML(t *testing.T) { + tests := []struct { + name string + body string + docName string + want string + stripped bool + }{ + { + name: "root-wrapped duplicate h1 stripped", + body: `["root",{},["h1",{},"晚会简报"],["p",{},"正文"]]`, + docName: "晚会简报", + want: `["root",{},["p",{},"正文"]]`, + stripped: true, + }, + { + name: "nested leaf text matches and stripped", + body: `["root",{},["h1",{"uuid":"x"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"晚会简报"]]],["p",{},"正文"]]`, + docName: "晚会简报", + want: `["root",{},["p",{},"正文"]]`, + stripped: true, + }, + { + name: "bare body without root wrapper", + body: `[["h1",{},"标题"],["p",{},"正文"]]`, + docName: "标题", + want: `[["p",{},"正文"]]`, + stripped: true, + }, + { + name: "case insensitive match", + body: `["root",{},["h1",{},"Weekly REPORT"],["p",{},"x"]]`, + docName: "weekly report", + want: `["root",{},["p",{},"x"]]`, + stripped: true, + }, + { + name: "distinct heading kept", + body: `["root",{},["h1",{},"背景"],["p",{},"正文"]]`, + docName: "晚会简报", + want: `["root",{},["h1",{},"背景"],["p",{},"正文"]]`, + stripped: false, + }, + { + name: "non-h1 leading node kept", + body: `["root",{},["h2",{},"晚会简报"],["p",{},"正文"]]`, + docName: "晚会简报", + want: `["root",{},["h2",{},"晚会简报"],["p",{},"正文"]]`, + stripped: false, + }, + { + name: "invalid json untouched", + body: `not json`, + docName: "x", + want: `not json`, + stripped: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, ok := stripLeadingDuplicateTitleJSONML(tc.body, tc.docName) + if ok != tc.stripped { + t.Fatalf("stripped = %v, want %v", ok, tc.stripped) + } + if got != tc.want { + t.Fatalf("body = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/keychain/keychain_windows.go b/internal/keychain/keychain_windows.go index cc715903..0ee89e28 100644 --- a/internal/keychain/keychain_windows.go +++ b/internal/keychain/keychain_windows.go @@ -34,18 +34,26 @@ import ( const regRootPath = `Software\DwsCli\keychain` -// StorageDir returns the storage directory for file-based keychain artifacts on Windows. +// StorageDir returns the storage directory for a given service name on Windows. +// The Windows keychain backend keeps secrets in DPAPI-protected HKCU registry +// values rather than on disk, so this path is used only by the portable +// auth-bundle export/import (internal/auth) to colocate config. When the +// DWS_KEYCHAIN_DIR environment variable is set (used by tests for isolation), +// the storage root is taken from that env var instead; otherwise it defaults +// to %LocalAppData%\<service>. func StorageDir(service string) string { if override := os.Getenv(StorageDirEnv); override != "" { return filepath.Join(override, service) } - if appData := os.Getenv("APPDATA"); appData != "" { - return filepath.Join(appData, "DwsCli", "keychain", service) + if local := os.Getenv("LocalAppData"); local != "" { + return filepath.Join(local, service) } - if home, err := os.UserHomeDir(); err == nil && home != "" { - return filepath.Join(home, "AppData", "Roaming", "DwsCli", "keychain", service) + home, err := os.UserHomeDir() + if err != nil || home == "" { + fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err) + return filepath.Join(".dws", "keychain", service) } - return filepath.Join(".dws", "keychain", service) + return filepath.Join(home, "AppData", "Local", service) } func registryPathForService(service string) string { diff --git a/internal/market/registry.go b/internal/market/registry.go index 755fd6db..4ea1a1e4 100644 --- a/internal/market/registry.go +++ b/internal/market/registry.go @@ -31,8 +31,14 @@ import ( ) const ( - defaultBaseURL = "https://pre-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") } diff --git a/internal/market/registry_test.go b/internal/market/registry_test.go index cd6e327d..5b030104 100644 --- a/internal/market/registry_test.go +++ b/internal/market/registry_test.go @@ -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) } diff --git a/internal/pat/browser_policy.go b/internal/pat/browser_policy.go index c6c474b3..5667c9fc 100644 --- a/internal/pat/browser_policy.go +++ b/internal/pat/browser_policy.go @@ -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 } diff --git a/internal/pat/browser_policy_test.go b/internal/pat/browser_policy_test.go index 7819f2c3..146fb352 100644 --- a/internal/pat/browser_policy_test.go +++ b/internal/pat/browser_policy_test.go @@ -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) + } +} diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index d512edf0..ec7e01a2 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -108,17 +108,16 @@ func resolveSessionIDFromEnv() string { // 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, `pat chmod` fails -// locally instead of letting the server reject the request later. Batch PAT -// tools receive the resolved agentCode in arguments, while the CLI also keeps -// exporting it through env for older gateway paths. +// 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_DINGTALK_AGENTCODE is kept as a compatibility alias for -// hosts that shipped the reversed prefix early. 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 - agentCodeEnvCompat = authpkg.AgentCodeEnvCompat + agentCodeEnv = authpkg.AgentCodeEnv ) // agentCodePattern is the validation regex for any --agentCode value @@ -130,9 +129,9 @@ const ( var agentCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) // resolveAgentCodeFromEnv returns the fallback agent code from the canonical -// DINGTALK_DWS_AGENTCODE env var, then the DWS_DINGTALK_AGENTCODE compatibility -// alias. The second return value reports the env name that was consumed (for -// error attribution); it is "" when both env vars are unset or blank. +// 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) { return authpkg.AgentCodeFromEnv() } @@ -152,35 +151,21 @@ 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. DWS_DINGTALK_AGENTCODE env var (compatibility fallback) -// 4. 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 "", apperrors.NewValidation( - fmt.Sprintf("flag --agentCode is required (or set env %s or %s)", agentCodeEnv, agentCodeEnvCompat), - apperrors.WithReason("missing_agent_code"), - apperrors.WithHint(fmt.Sprintf("dws pat chmod <scope>... --agentCode <id>\n or: export %s=<id>", agentCodeEnv)), - apperrors.WithActions( - "dws pat chmod <scope>... --agentCode <id>", - fmt.Sprintf("export %s=<id>", agentCodeEnv), - fmt.Sprintf("export %s=<id>", agentCodeEnvCompat), - ), - ) - } return "", nil } if err := validateAgentCode(code); err != nil { @@ -267,11 +252,19 @@ grantType 规则: permanent 永久有效 批量授权: - 产品线批量授权推荐使用 --recommend --product <productCode[,productCode]>。 - --products / --domain / --domains 保持兼容;裸 --recommend 保持可用, - 但会按推荐集合规划,可能跨产品。 - 批量授权未传 --yes 时会打开授权确认页;传 --yes 时才直接写入服务端选中的权限。 - 批量授权未显式指定 --grant-type 且没有 session-id 时,会以 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 时才直接写入服务端选中的权限。批量授权未显式指定 + --grant-type 且没有 session-id 时,会以 permanent 作为本次页面确认后的授权时长。 + +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 { @@ -287,7 +280,7 @@ grantType 规则: dws pat chmod --recommend --grant-type session --session-id session-xxx`, RunE: func(cmd *cobra.Command, args []string) error { flagVal, _ := cmd.Flags().GetString("agentCode") - agentCode, err := resolveAgentCode(flagVal, true) + agentCode, err := resolveAgentCode(flagVal) if err != nil { return err } @@ -430,7 +423,7 @@ grantType 规则: } chmodCmd.Flags().String("agentCode", "", - "Agent 唯一标识(必填;也可通过 env DINGTALK_DWS_AGENTCODE/DWS_DINGTALK_AGENTCODE 注入,flag 优先;会进入 batch 参数并同步注入兼容 env)") + "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 等价") @@ -640,6 +633,12 @@ func RunLoginRecommendAuthorizationWithOptions(ctx context.Context, c edition.To return err } } + if recommendPlan && (allGranted || len(scopes) == 0) { + if output != nil { + _, _ = fmt.Fprintln(output, "推荐权限已全部授权或没有可授权项") + } + return nil + } if opts.ProductSelector != nil { products, err := extractLoginRecommendProducts(planResult) if err != nil { diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index 9966fab6..1d3e8625 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -14,6 +14,7 @@ package pat import ( + "bytes" "context" "errors" "io" @@ -29,8 +30,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 @@ -267,12 +268,40 @@ 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 setBatchYesForTest(t *testing.T, cmd *cobra.Command) { t.Helper() - cmd.Flags().BoolP("yes", "y", false, "test-only root confirmation flag") - if err := cmd.Flags().Set("yes", "true"); err != nil { - t.Fatalf("set --yes: %v", err) - } + attachRootYesFlag(t, cmd, true) } func TestRegisterCommands_OnlyExposesChmodForAuthorization(t *testing.T) { @@ -297,6 +326,63 @@ 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 只返回授权计划", + "未加 --yes 时会打开授权确认页", + "传 --yes 时才直接写入", + "由服务端默认兜底", + "aitable.record:read aitable.record:write --grant-type permanent", + "dws pat chmod --products calendar,aitable", + "dws pat chmod --recommend --grant-type session", + } { + 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{ @@ -470,6 +556,328 @@ func TestChmod_productsSessionModePassesIdentityArgsAndCompatEnv(t *testing.T) { } } +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: 2, + }, + { + 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_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) { t.Setenv(agentCodeEnv, "qoderwork") t.Setenv(sessionIDEnvDWS, "env-session-123") @@ -507,8 +915,7 @@ func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) { } func TestChmod_batchPlanRetriesWithoutIdentityArgsForCompat(t *testing.T) { - t.Setenv(agentCodeEnv, "") - t.Setenv(agentCodeEnvCompat, "qoderwork") + t.Setenv(agentCodeEnv, "qoderwork") fake := &sequenceToolCaller{ errs: []error{ apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.", @@ -550,8 +957,7 @@ func TestChmod_batchPlanRetriesWithoutIdentityArgsForCompat(t *testing.T) { } func TestChmod_batchGrantRetriesWithoutIdentityArgsForCompat(t *testing.T) { - t.Setenv(agentCodeEnv, "") - t.Setenv(agentCodeEnvCompat, "qoderwork") + t.Setenv(agentCodeEnv, "qoderwork") fake := &sequenceToolCaller{ errs: []error{ apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.", @@ -841,6 +1247,35 @@ func TestLoginRecommendAuthorizationWithoutSelectorKeepsSinglePlan(t *testing.T) } } +func TestLoginRecommendAuthorizationRecommendedAlreadyGrantedSkipsSelectorAndGrant(t *testing.T) { + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"data":{"allGranted":false,"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":[]}}`, + }} + var out bytes.Buffer + err := RunLoginRecommendAuthorizationWithOptions(context.Background(), fake, &out, LoginRecommendOptions{ + ScopeMode: LoginRecommendScopeRecommended, + ProductSelector: func([]LoginRecommendProduct) ([]string, error) { + t.Fatal("already-granted recommended plan must not ask for product selection") + return nil, nil + }, + }) + if err != nil { + t.Fatalf("RunLoginRecommendAuthorizationWithOptions error = %v", err) + } + if len(fake.calls) != 1 { + t.Fatalf("CallTool count = %d, want only initial recommend plan", len(fake.calls)) + } + if fake.calls[0].tool != patBatchPlanToolName { + t.Fatalf("tool sequence = %q, want only %q", fake.calls[0].tool, patBatchPlanToolName) + } + if strings.Contains(out.String(), "flowId") { + t.Fatalf("output = %q, must not include authorization flow", out.String()) + } + if !strings.Contains(out.String(), "推荐权限已全部授权或没有可授权项") { + t.Fatalf("output = %q, want already-granted message", out.String()) + } +} + func TestLoginRecommendAuthorizationAllScopeModePlansAllProductScopes(t *testing.T) { fake := &sequenceToolCaller{responses: []string{ `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"},{"scope":"calendar.event:write","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read","calendar.event:write"]}}`, @@ -997,9 +1432,9 @@ func TestChmod_agentCode_env_fallback(t *testing.T) { } } -func TestChmod_agentCode_compatEnvFallback(t *testing.T) { +func TestChmod_agentCode_reversedEnvIgnored(t *testing.T) { t.Setenv(agentCodeEnv, "") - t.Setenv(agentCodeEnvCompat, "compatwork") + t.Setenv("DWS_DINGTALK_AGENTCODE", "compatwork") fake := &fakeToolCaller{resultOK: true} cmd := buildChmod(t, fake) @@ -1011,11 +1446,35 @@ func TestChmod_agentCode_compatEnvFallback(t *testing.T) { if fake.gotTool != patBatchGrantToolName { t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName) } - if got := fake.gotArgs["agentCode"]; got != "compatwork" { - t.Fatalf("batch agentCode = %#v, want compatwork", got) + 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 != "compatwork" { - t.Fatalf("%s during CallTool = %q, want compatwork", agentCodeEnv, got) + if got := fake.gotAgentEnv; 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("agentCode arg must be omitted for server default path: %#v", fake.gotArgs) + } + if got := fake.gotAgentEnv; got != "" { + t.Fatalf("%s during CallTool = %q, want empty for server default path", agentCodeEnv, got) } } @@ -1047,26 +1506,6 @@ func TestChmod_agentCode_envServerMismatchFails(t *testing.T) { } } -func TestChmod_withoutAgentCodeFailsBeforeMCP(t *testing.T) { - t.Setenv(agentCodeEnv, "") - t.Setenv(agentCodeEnvCompat, "") - - fake := &fakeToolCaller{resultOK: true} - cmd := buildChmod(t, fake) - _ = cmd.Flags().Set("grant-type", "once") - - err := cmd.RunE(cmd, []string{"aitable.record:read"}) - if err == nil { - t.Fatal("chmod RunE error = nil, want missing agentCode error") - } - if !strings.Contains(err.Error(), "flag --agentCode is required") { - t.Fatalf("error = %q, want missing agentCode hint", err.Error()) - } - if fake.callN != 0 { - t.Fatalf("CallTool was invoked %d times; missing agentCode must fail before MCP", fake.callN) - } -} - func TestCallPATToolWithLegacyFallback_emptyCanonicalResultDoesNotRetryLegacyAlias(t *testing.T) { fake := &fallbackToolCaller{} canonicalArgs := map[string]any{ @@ -1525,27 +1964,27 @@ func TestChmod_agentCode_flag_wins_over_env(t *testing.T) { } } -// TestChmod_agentCode_legacy_env_not_recognized is a reverse-guard: after -// the SSOT hard-removal of the DWS_AGENTCODE alias, exporting only the -// legacy env MUST NOT satisfy the required agentCode contract. +// 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(agentCodeEnvCompat, "") 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") - err := cmd.RunE(cmd, []string{"aitable.record:read"}) - if err == nil { - t.Fatal("chmod RunE error = nil, want missing agentCode error") + 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 !strings.Contains(err.Error(), "flag --agentCode is required") { - t.Fatalf("error = %q, want missing agentCode hint", err.Error()) + if fake.callN != 1 { + t.Fatalf("CallTool was invoked %d times; legacy env should be ignored but request should continue", fake.callN) } - if fake.callN != 0 { - t.Fatalf("CallTool was invoked %d times; legacy env must not satisfy agentCode", 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) @@ -1590,21 +2029,17 @@ func TestResolveAgentCodeFromEnv(t *testing.T) { code, src, "qoderwork", agentCodeEnv) } - t.Setenv(agentCodeEnvCompat, "compatwork") - if code, src := resolveAgentCodeFromEnv(); code != "qoderwork" || src != agentCodeEnv { - t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want primary (%q, %q)", - 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, "") - if code, src := resolveAgentCodeFromEnv(); code != "compatwork" || src != agentCodeEnvCompat { - t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want compat (%q, %q)", - code, src, "compatwork", agentCodeEnvCompat) - } - - // Empty primary + empty compat → ("", ""). - t.Setenv(agentCodeEnv, "") - t.Setenv(agentCodeEnvCompat, "") + t.Setenv("DWS_DINGTALK_AGENTCODE", "") if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" { t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty", code, src) } @@ -1612,7 +2047,6 @@ func TestResolveAgentCodeFromEnv(t *testing.T) { // Reverse-guard: legacy DWS_AGENTCODE MUST NOT be picked up when the // canonical env is unset — it was hard-removed as a legacy alias. t.Setenv(agentCodeEnv, "") - t.Setenv(agentCodeEnvCompat, "") t.Setenv("DWS_AGENTCODE", "legacy") if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" { t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty — legacy DWS_AGENTCODE must be ignored", diff --git a/internal/pat/pat.go b/internal/pat/pat.go index ab55a162..5e535f00 100644 --- a/internal/pat/pat.go +++ b/internal/pat/pat.go @@ -36,10 +36,16 @@ 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 独立。 - pat chmod 必须传 --agentCode,或设置 DINGTALK_DWS_AGENTCODE / - DWS_DINGTALK_AGENTCODE;CLI 会把 agentCode 放入 batch 请求参数, - 并同步注入 gateway 兼容身份头。 + pat chmod 可传 --agentCode,或设置 DINGTALK_DWS_AGENTCODE; + CLI 会把显式 agentCode 放入 batch 请求参数, + 并同步注入 gateway 兼容身份头。未传 agentCode 时由服务端默认兜底。 浏览器策略生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。 写入 agent 策略需显式传 --agentCode;不传则写入全局默认策略。 diff --git a/internal/registry/personas.yaml b/internal/registry/personas.yaml index d0ca90d3..fc262e8e 100644 --- a/internal/registry/personas.yaml +++ b/internal/registry/personas.yaml @@ -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: - "先获取原始数据,再做结构化汇总与分发。" diff --git a/internal/transport/client.go b/internal/transport/client.go index e22b90f2..2dc94734 100644 --- a/internal/transport/client.go +++ b/internal/transport/client.go @@ -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 } diff --git a/internal/transport/client_extra_test.go b/internal/transport/client_extra_test.go index 395d4135..05177cbd 100644 --- a/internal/transport/client_extra_test.go +++ b/internal/transport/client_extra_test.go @@ -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 { diff --git a/internal/transport/client_test.go b/internal/transport/client_test.go index 27d78c65..969a8796 100644 --- a/internal/transport/client_test.go +++ b/internal/transport/client_test.go @@ -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 } diff --git a/pkg/config/constants.go b/pkg/config/constants.go index 8f2bbe65..c986e45a 100644 --- a/pkg/config/constants.go +++ b/pkg/config/constants.go @@ -163,7 +163,7 @@ const ( const ( // DefaultMCPBaseURL is the DingTalk MCP base URL. // Override at runtime via ~/.dws/mcp_url file. - DefaultMCPBaseURL = "https://pre-mcp.dingtalk.com" + DefaultMCPBaseURL = "https://mcp.dingtalk.com" // DefaultTerminalBaseURL is the DingTalk developer platform base URL. // Override at runtime via ~/.dws/terminal_url file. @@ -189,7 +189,7 @@ func DefaultConfigDir() string { // GetMCPBaseURL returns the MCP base URL with priority: // 1. ~/.dws/mcp_url file content (for custom environment) -// 2. Default value (https://pre-mcp.dingtalk.com) +// 2. Default value (https://mcp.dingtalk.com) func GetMCPBaseURL() string { mcpURLPath := filepath.Join(DefaultConfigDir(), "mcp_url") if data, err := os.ReadFile(mcpURLPath); err == nil { diff --git a/pkg/config/constants_test.go b/pkg/config/constants_test.go index 4cb538d5..c9e538b0 100644 --- a/pkg/config/constants_test.go +++ b/pkg/config/constants_test.go @@ -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) diff --git a/pkg/edition/default.go b/pkg/edition/default.go index f08c38ee..784cb158 100644 --- a/pkg/edition/default.go +++ b/pkg/edition/default.go @@ -46,7 +46,7 @@ func openSupplementServers() []ServerInfo { { ID: "aitable-form", Name: "AI 多维表(表单)", - Endpoint: "https://pre-mcp-gw.dingtalk.com/server/bb2984ee6b10c1560b4fe943ca620f646bed31f215c551a53abf040b52591a95", + Endpoint: "https://mcp-gw.dingtalk.com/server/bb2984ee6b10c1560b4fe943ca620f646bed31f215c551a53abf040b52591a95", Prefixes: []string{"form", "share_form"}, }, } diff --git a/pkg/edition/edition.go b/pkg/edition/edition.go index 0db286d3..56702317 100644 --- a/pkg/edition/edition.go +++ b/pkg/edition/edition.go @@ -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 +} diff --git a/pkg/edition/edition_test.go b/pkg/edition/edition_test.go new file mode 100644 index 00000000..0be785e8 --- /dev/null +++ b/pkg/edition/edition_test.go @@ -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") + } +} diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index 745a498f..d2ade351 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -13,8 +13,14 @@ 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:-}" +# Auto-fallback Gitee mirror used when GitHub is unreachable (see pick_source). +GITEE_FALLBACK_REPO="${DWS_GITEE_FALLBACK_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}" VERSION="${DWS_VERSION:-latest}" SKILL_NAME="dws" ROOT="${DWS_SKILLS_ROOT:-$PWD}" @@ -29,10 +35,43 @@ 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 +} + +pick_source() { + [ -n "$GITEE_REPO" ] && return 0 + [ "${DWS_NO_FALLBACK:-0}" = "1" ] && return 0 + curl -fsS --connect-timeout 5 --max-time 12 -o /dev/null "https://github.com/${REPO}/releases/latest" 2>/dev/null && return 0 + GITEE_REPO="$GITEE_FALLBACK_REPO" + printf ' ⚠ GitHub 不可达,自动切换国内 Gitee 镜像: %s\n' "$GITEE_REPO" +} + 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 +79,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" @@ -154,6 +207,7 @@ install_skills_to_root() { main() { need_cmd curl + pick_source resolve_version printf '\n' @@ -166,7 +220,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" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 5d1cbb1c..8e220ba8 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -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,10 @@ $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 { "" } +# Auto-fallback Gitee mirror used when GitHub is unreachable (see Resolve-Source). +$GiteeFallbackRepo = if ($env:DWS_GITEE_FALLBACK_REPO) { $env:DWS_GITEE_FALLBACK_REPO } else { "DingTalk-Real-AI/dingtalk-workspace-cli" } $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 +124,61 @@ 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-Source { + # Explicit DWS_GITEE_REPO wins; else probe GitHub and fall back to Gitee when unreachable. + if ($GiteeRepo -ne "") { return } + if ($env:DWS_NO_FALLBACK -eq "1") { return } + try { + Invoke-WebRequest -Uri "https://github.com/$Repo/releases/latest" -Method Head ` + -TimeoutSec 12 -UseBasicParsing -ErrorAction Stop 2>$null | Out-Null + return + } catch { + $script:GiteeRepo = $GiteeFallbackRepo + Write-Say "⚠ GitHub 不可达,自动切换国内 Gitee 镜像: $script:GiteeRepo" + } +} + 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 +348,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 +361,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 +543,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 @@ -546,6 +607,10 @@ $SourceRoot = Resolve-SourceRoot Write-Banner +# Pick GitHub vs Gitee mirror (auto-fallback when GitHub is unreachable). +# Skipped when installing from a local source checkout (no download needed). +if (-not $SourceRoot) { Resolve-Source } + if (!$NoSkills) { Resolve-SkillMode } diff --git a/scripts/install.sh b/scripts/install.sh index 87512e95..5d6f4cb7 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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,15 @@ 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:-}" +# Auto-fallback: when DWS_GITEE_REPO is not set, the installer probes GitHub and, +# if it is unreachable (typical in mainland China), automatically switches to this +# Gitee mirror — so a plain `curl … | sh` works everywhere with no env var. +# Set DWS_NO_FALLBACK=1 to disable the probe and force GitHub. +GITEE_FALLBACK_REPO="${DWS_GITEE_FALLBACK_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}" INSTALL_DIR="${DWS_INSTALL_DIR:-$HOME/.local/bin}" INSTALL_NAME="${DWS_INSTALL_NAME:-$BIN_NAME}" VERSION="${DWS_VERSION:-latest}" @@ -77,6 +88,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" @@ -111,11 +158,36 @@ detect_arch() { esac } +# Decide the download source. An explicit DWS_GITEE_REPO always wins. Otherwise +# probe GitHub Releases; if it is unreachable (typical in mainland China), switch +# GITEE_REPO to the mirror so every subsequent resolve/download uses Gitee. +pick_source() { + [ -n "$GITEE_REPO" ] && return 0 + [ "${DWS_NO_FALLBACK:-0}" = "1" ] && return 0 + if need_cmd curl; then + curl -fsS --connect-timeout 5 --max-time 12 -o /dev/null "https://github.com/${REPO}/releases/latest" 2>/dev/null && return 0 + elif need_cmd wget; then + wget -q --timeout=12 --tries=1 -O /dev/null "https://github.com/${REPO}/releases/latest" 2>/dev/null && return 0 + fi + GITEE_REPO="$GITEE_FALLBACK_REPO" + say "⚠ GitHub 不可达,自动切换国内 Gitee 镜像: ${GITEE_REPO}" +} + # 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 +473,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 +484,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 +555,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 @@ -559,6 +633,10 @@ main() { print_banner + # Pick GitHub vs Gitee mirror (auto-fallback when GitHub is unreachable). + # Skipped when installing from a local source checkout (no download needed). + [ -z "$source_root" ] && pick_source + # Resolve skill mode only when we are actually going to touch skills. if [ "$NO_SKILLS" != "1" ]; then resolve_skill_mode diff --git a/scripts/release/sync-to-gitee.sh b/scripts/release/sync-to-gitee.sh new file mode 100755 index 00000000..3ee75504 --- /dev/null +++ b/scripts/release/sync-to-gitee.sh @@ -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" diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 67e7ab91..5de58516 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -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` diff --git a/skills/mono/references/best_practices/01-messaging.md b/skills/mono/references/best_practices/01-messaging.md index fb95e817..966a79ee 100644 --- a/skills/mono/references/best_practices/01-messaging.md +++ b/skills/mono/references/best_practices/01-messaging.md @@ -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>`(备选) | diff --git a/skills/mono/references/intent-guide.md b/skills/mono/references/intent-guide.md index 80749e46..a24e6172 100644 --- a/skills/mono/references/intent-guide.md +++ b/skills/mono/references/intent-guide.md @@ -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` --- diff --git a/skills/mono/references/products/aiapp.md b/skills/mono/references/products/aiapp.md deleted file mode 100644 index ed25fea2..00000000 --- a/skills/mono/references/products/aiapp.md +++ /dev/null @@ -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 "创建一个天气查询应用"` | diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index 60b1efee..b52ed1d5 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -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 ``` @@ -921,16 +940,18 @@ Flags: - --output 如果指定目录,文件名会从下载 URL 中自动推断 ``` -#### 资源链接形态分流 — 按 content 里的链接形态选下载方式 +#### 资源链接形态分流 — 按 content 里的链接 host 选下载方式 -`message list` / `message list-all` 拉到的消息,`content` 里的资源**不一定都是 mediaId**,下载方式由链接形态决定,按下表三类分流: +`message list` / `message list-all` 拉到的消息,`content` 里的资源**不一定都是 mediaId**,下载方式由链接的 host 决定、不由文件扩展名决定;**首选对应 dws 命令**,裸 curl 仅对明确的公开直链有效。按下表分流、不要混用: | `content` 里的形态 | 资源性质 | 下载方式 | |---|---|---| -| 消息含 `mediaId`(图片 / 视频 / 语音等) | 钉钉消息媒体资源 | **`dws chat message download-media`**(见上一节,需 `--message-id` + `--open-conversation-id`) | -| `[文件] xxx fileId: <fileId>`(钉盘文件) | 钉盘文件 | `dws drive download --node <fileId> --output <路径>` | -| `https://alidocs.dingtalk.com/i/nodes/<nodeId>`(钉钉文档 / 视频等节点) | 文档 / 钉盘节点 | 读内容用 `dws doc`,下文件用 `dws drive download` | +| `mediaId=...`(图片 / 视频 / 语音,扩展名任意) | 钉钉消息媒体;**host 不唯一**:`down.dingtalk.com/media`(公开直链)或 `*.trans.dingtalk.com/...?Expires=&Signature=`(签名 + 会过期) | **首选 `dws chat message download-media`**(内部取最新下载 URL,不受过期影响,需 `--message-id` + `--open-conversation-id`)。仅当链接是 `down.dingtalk.com/media` 公开直链时,可 `curl -sL -o` 快捷下载;**签名/过期链(`trans.dingtalk.com`、带 `Signature`/`Expires`)不要裸 curl,过期会 403** | +| `[文件] xxx fileId: <fileId>`(钉盘文件) | 钉盘临时签名链接 | `dws drive download --node <fileId> --output <路径>`(裸 curl 不通用,需签名头) | +| `https://alidocs.dingtalk.com/i/nodes/<nodeId>`(钉钉文档 / .adoc / 视频 .mov 等节点) | 文档 / 钉盘节点 | 读内容用 `dws doc`,下文件用 `dws drive download`(裸 curl 只得 HTML 预览页) | +> - 下载到本地后用 `file <文件>` 判断真实类型:部分链接扩展名是 `.unknown`、服务端按 `application/octet-stream` 返回,仍可正常下载。 +> - 图片消息还会附带 AI 识别的内容描述(`<imageContent>...</imageContent>`),不下载也能理解图意。 > - 钉盘文件在 `content` 里给的是 `fileId`、不是 mediaId,必须走 `dws drive download`(下载链接是带签名头的临时链接,裸 curl 不通用)。 > - alidocs 节点裸 curl 只会拿到 HTML 预览页、不是文件本体,需走 `dws doc`(读内容)或 `dws drive download`(下文件)。 @@ -1145,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>` @@ -1165,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` @@ -1220,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`) @@ -1446,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 | @@ -1482,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` 指定返回条数 diff --git a/skills/mono/references/products/devdoc.md b/skills/mono/references/products/devdoc.md index e4116d9b..862e7b1d 100644 --- a/skills/mono/references/products/devdoc.md +++ b/skills/mono/references/products/devdoc.md @@ -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 只查**官方开发者文档** diff --git a/skills/mono/references/products/doc/doc-create.md b/skills/mono/references/products/doc/doc-create.md index 6ade971b..3f92a4ac 100644 --- a/skills/mono/references/products/doc/doc-create.md +++ b/skills/mono/references/products/doc/doc-create.md @@ -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 -`。 diff --git a/skills/mono/references/products/doc/style/doc-create-workflow.md b/skills/mono/references/products/doc/style/doc-create-workflow.md index 6c1f1282..c0503ed8 100644 --- a/skills/mono/references/products/doc/style/doc-create-workflow.md +++ b/skills/mono/references/products/doc/style/doc-create-workflow.md @@ -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`。 diff --git a/skills/mono/references/products/simple.md b/skills/mono/references/products/simple.md index cdaa9e27..a4f841ee 100644 --- a/skills/mono/references/products/simple.md +++ b/skills/mono/references/products/simple.md @@ -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 | diff --git a/skills/mono/scripts/aiapp_create_and_poll.py b/skills/mono/scripts/aiapp_create_and_poll.py deleted file mode 100644 index 663184a6..00000000 --- a/skills/mono/scripts/aiapp_create_and_poll.py +++ /dev/null @@ -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() diff --git a/skills/mono/scripts/chat_history_with_user.py b/skills/mono/scripts/chat_history_with_user.py index 8068be38..4c92627c 100644 --- a/skills/mono/scripts/chat_history_with_user.py +++ b/skills/mono/scripts/chat_history_with_user.py @@ -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} 页)") diff --git a/skills/multi/dingtalk-aiapp/SKILL.md b/skills/multi/dingtalk-aiapp/SKILL.md deleted file mode 100644 index c9162821..00000000 --- a/skills/multi/dingtalk-aiapp/SKILL.md +++ /dev/null @@ -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` diff --git a/skills/multi/dingtalk-aiapp/references/aiapp.md b/skills/multi/dingtalk-aiapp/references/aiapp.md deleted file mode 100644 index 575edfe1..00000000 --- a/skills/multi/dingtalk-aiapp/references/aiapp.md +++ /dev/null @@ -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 "创建一个天气查询应用"` | diff --git a/skills/multi/dingtalk-aiapp/scripts/aiapp_create_and_poll.py b/skills/multi/dingtalk-aiapp/scripts/aiapp_create_and_poll.py deleted file mode 100644 index 663184a6..00000000 --- a/skills/multi/dingtalk-aiapp/scripts/aiapp_create_and_poll.py +++ /dev/null @@ -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() diff --git a/skills/multi/dingtalk-chat/references/chat.md b/skills/multi/dingtalk-chat/references/chat.md index 70e61ed5..c30566e7 100644 --- a/skills/multi/dingtalk-chat/references/chat.md +++ b/skills/multi/dingtalk-chat/references/chat.md @@ -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 ``` @@ -921,16 +940,18 @@ Flags: - --output 如果指定目录,文件名会从下载 URL 中自动推断 ``` -#### 资源链接形态分流 — 按 content 里的链接形态选下载方式 +#### 资源链接形态分流 — 按 content 里的链接 host 选下载方式 -`message list` / `message list-all` 拉到的消息,`content` 里的资源**不一定都是 mediaId**,下载方式由链接形态决定,按下表三类分流: +`message list` / `message list-all` 拉到的消息,`content` 里的资源**不一定都是 mediaId**,下载方式由链接的 host 决定、不由文件扩展名决定;**首选对应 dws 命令**,裸 curl 仅对明确的公开直链有效。按下表分流、不要混用: | `content` 里的形态 | 资源性质 | 下载方式 | |---|---|---| -| 消息含 `mediaId`(图片 / 视频 / 语音等) | 钉钉消息媒体资源 | **`dws chat message download-media`**(见上一节,需 `--message-id` + `--open-conversation-id`) | -| `[文件] xxx fileId: <fileId>`(钉盘文件) | 钉盘文件 | `dws drive download --node <fileId> --output <路径>` | -| `https://alidocs.dingtalk.com/i/nodes/<nodeId>`(钉钉文档 / 视频等节点) | 文档 / 钉盘节点 | 读内容用 `dws doc`,下文件用 `dws drive download` | +| `mediaId=...`(图片 / 视频 / 语音,扩展名任意) | 钉钉消息媒体;**host 不唯一**:`down.dingtalk.com/media`(公开直链)或 `*.trans.dingtalk.com/...?Expires=&Signature=`(签名 + 会过期) | **首选 `dws chat message download-media`**(内部取最新下载 URL,不受过期影响,需 `--message-id` + `--open-conversation-id`)。仅当链接是 `down.dingtalk.com/media` 公开直链时,可 `curl -sL -o` 快捷下载;**签名/过期链(`trans.dingtalk.com`、带 `Signature`/`Expires`)不要裸 curl,过期会 403** | +| `[文件] xxx fileId: <fileId>`(钉盘文件) | 钉盘临时签名链接 | `dws drive download --node <fileId> --output <路径>`(裸 curl 不通用,需签名头) | +| `https://alidocs.dingtalk.com/i/nodes/<nodeId>`(钉钉文档 / .adoc / 视频 .mov 等节点) | 文档 / 钉盘节点 | 读内容用 `dws doc`,下文件用 `dws drive download`(裸 curl 只得 HTML 预览页) | +> - 下载到本地后用 `file <文件>` 判断真实类型:部分链接扩展名是 `.unknown`、服务端按 `application/octet-stream` 返回,仍可正常下载。 +> - 图片消息还会附带 AI 识别的内容描述(`<imageContent>...</imageContent>`),不下载也能理解图意。 > - 钉盘文件在 `content` 里给的是 `fileId`、不是 mediaId,必须走 `dws drive download`(下载链接是带签名头的临时链接,裸 curl 不通用)。 > - alidocs 节点裸 curl 只会拿到 HTML 预览页、不是文件本体,需走 `dws doc`(读内容)或 `dws drive download`(下文件)。 @@ -1145,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>` @@ -1165,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` @@ -1220,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`) @@ -1446,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 | @@ -1482,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` 指定返回条数 diff --git a/skills/multi/dingtalk-chat/scripts/chat_history_with_user.py b/skills/multi/dingtalk-chat/scripts/chat_history_with_user.py index 8068be38..4c92627c 100644 --- a/skills/multi/dingtalk-chat/scripts/chat_history_with_user.py +++ b/skills/multi/dingtalk-chat/scripts/chat_history_with_user.py @@ -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} 页)") diff --git a/skills/multi/dingtalk-devdoc/SKILL.md b/skills/multi/dingtalk-devdoc/SKILL.md index 2fb09b03..9c717654 100644 --- a/skills/multi/dingtalk-devdoc/SKILL.md +++ b/skills/multi/dingtalk-devdoc/SKILL.md @@ -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 "<接口名或场景>"` | ## 跨产品协作 diff --git a/skills/multi/dingtalk-devdoc/references/devdoc.md b/skills/multi/dingtalk-devdoc/references/devdoc.md index e4116d9b..862e7b1d 100644 --- a/skills/multi/dingtalk-devdoc/references/devdoc.md +++ b/skills/multi/dingtalk-devdoc/references/devdoc.md @@ -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 只查**官方开发者文档** diff --git a/skills/multi/dingtalk-doc/references/doc/doc-create.md b/skills/multi/dingtalk-doc/references/doc/doc-create.md index f5093586..11a2daa5 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-create.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-create.md @@ -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 -`。 diff --git a/skills/multi/dingtalk-doc/references/doc/style/doc-create-workflow.md b/skills/multi/dingtalk-doc/references/doc/style/doc-create-workflow.md index 6c1f1282..c0503ed8 100644 --- a/skills/multi/dingtalk-doc/references/doc/style/doc-create-workflow.md +++ b/skills/multi/dingtalk-doc/references/doc/style/doc-create-workflow.md @@ -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`。 diff --git a/skills/multi/dingtalk-skill/references/skill.md b/skills/multi/dingtalk-skill/references/skill.md index d9a416c3..850af6da 100644 --- a/skills/multi/dingtalk-skill/references/skill.md +++ b/skills/multi/dingtalk-skill/references/skill.md @@ -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` | 发布结果(成功或错误信息) | 确认企业技能库已更新 | diff --git a/skills_embed.go b/skills_embed.go new file mode 100644 index 00000000..9b8bd791 --- /dev/null +++ b/skills_embed.go @@ -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 diff --git a/test/cli/testmain_test.go b/test/cli/testmain_test.go index 18d7884d..27655a0f 100644 --- a/test/cli/testmain_test.go +++ b/test/cli/testmain_test.go @@ -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) diff --git a/test/cli_compat/devdoc_test.go b/test/cli_compat/devdoc_test.go index 755db3bf..6fb82af3 100644 --- a/test/cli_compat/devdoc_test.go +++ b/test/cli_compat/devdoc_test.go @@ -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) +} diff --git a/test/cli_compat/helpers_test.go b/test/cli_compat/helpers_test.go index 00df6928..1433dd08 100644 --- a/test/cli_compat/helpers_test.go +++ b/test/cli_compat/helpers_test.go @@ -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 diff --git a/test/cli_compat/testmain_test.go b/test/cli_compat/testmain_test.go index 4099652b..bbd01cd1 100644 --- a/test/cli_compat/testmain_test.go +++ b/test/cli_compat/testmain_test.go @@ -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) diff --git a/test/contract/mcp_docs_coverage_test.go b/test/contract/mcp_docs_coverage_test.go index ceaeb7c9..5c433c60 100644 --- a/test/contract/mcp_docs_coverage_test.go +++ b/test/contract/mcp_docs_coverage_test.go @@ -361,7 +361,7 @@ func newDocsMCPGateway(t *testing.T, expectations []docsServerExpectation) *http 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 diff --git a/test/integration/extensions/extensions_test.go b/test/integration/extensions/extensions_test.go index 54e654d0..bb171de4 100644 --- a/test/integration/extensions/extensions_test.go +++ b/test/integration/extensions/extensions_test.go @@ -237,7 +237,7 @@ func newRuntimeMarketServer(t *testing.T, fixture mockmcp.Fixture) *httptest.Ser mux := http.NewServeMux() srv := 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) { response := market.ListResponse{} for _, server := range fixture.Servers { response.Servers = append(response.Servers, market.ServerEnvelope{ diff --git a/test/integration/extensions/protocol_evolution_test.go b/test/integration/extensions/protocol_evolution_test.go index 83f8f270..f766eccb 100644 --- a/test/integration/extensions/protocol_evolution_test.go +++ b/test/integration/extensions/protocol_evolution_test.go @@ -134,7 +134,7 @@ func newEvolvingRuntimeMarketServer(t *testing.T, fixture mockmcp.Fixture) *evol s.SetFixture(t, fixture) mux := http.NewServeMux() - mux.HandleFunc("/cli/discovery/apis", s.serveMarketServers) + mux.HandleFunc("/cli/discovery/apis/bamboo", s.serveMarketServers) mux.HandleFunc("/mcp/market/detail", s.serveMarketDetail) mux.HandleFunc("/", s.serveMCP) s.server = httptest.NewServer(mux) diff --git a/test/integration/integration_test.go b/test/integration/integration_test.go index 1db3657c..430f4b7b 100644 --- a/test/integration/integration_test.go +++ b/test/integration/integration_test.go @@ -20,7 +20,7 @@ func TestEndToEndDiscoveryFlow(t *testing.T) { server := httptest.NewServer(mux) defer server.Close() - 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) { payload := market.ListResponse{ Metadata: market.ListMetadata{Count: 1}, Servers: []market.ServerEnvelope{ diff --git a/test/integration/recovery/recovery_test.go b/test/integration/recovery/recovery_test.go index a2791d13..c79b07fa 100644 --- a/test/integration/recovery/recovery_test.go +++ b/test/integration/recovery/recovery_test.go @@ -156,7 +156,7 @@ func TestRecoveryClosedLoopDoesNotRecursivelyCaptureExecuteFailures(t *testing.T t.Fatalf("list_bases calls = %d, want 1", got) } if got := calls.docSearch.Load(); got < 1 { - t.Fatalf("search_open_platform_docs calls = %d, want at least 1", got) + t.Fatalf("search_open_platform_docs_rag calls = %d, want at least 1", got) } } @@ -251,7 +251,7 @@ func newRecoveryRuntimeServer(t *testing.T) (*httptest.Server, *recoveryRuntimeC writeJSONRPCResult(t, w, req["id"], map[string]any{ "tools": []map[string]any{ { - "name": "search_open_platform_docs", + "name": "search_open_platform_docs_rag", "title": "Search Docs", "description": "Search docs", "inputSchema": map[string]any{"type": "object"}, @@ -261,7 +261,7 @@ func newRecoveryRuntimeServer(t *testing.T) (*httptest.Server, *recoveryRuntimeC case "tools/call": params, _ := req["params"].(map[string]any) name, _ := params["name"].(string) - if name != "search_open_platform_docs" { + if name != "search_open_platform_docs_rag" { t.Fatalf("unexpected devdoc tool %q", name) } calls.docSearch.Add(1) @@ -313,8 +313,8 @@ func writeRecoveryCatalogFixture(t *testing.T, baseURL string) string { "endpoint": baseURL + "/server/devdoc", "tools": []any{ map[string]any{ - "rpc_name": "search_open_platform_docs", - "canonical_path": "devdoc.search_open_platform_docs", + "rpc_name": "search_open_platform_docs_rag", + "canonical_path": "devdoc.search_open_platform_docs_rag", }, }, }, diff --git a/test/mock_mcp/server.go b/test/mock_mcp/server.go index 387240cf..f641ee56 100644 --- a/test/mock_mcp/server.go +++ b/test/mock_mcp/server.go @@ -24,7 +24,7 @@ func NewServer(fixture Fixture) (*Server, error) { s := &Server{Fixture: fixture} mux := http.NewServeMux() - mux.HandleFunc("/cli/discovery/apis", s.serveMarketServers) + mux.HandleFunc("/cli/discovery/apis/bamboo", s.serveMarketServers) mux.HandleFunc("/mcp/market/detail", s.serveMarketDetail) mux.HandleFunc("/", s.serveMCP) @@ -45,7 +45,7 @@ func DefaultServer() *Server { } func (s *Server) MarketURL() string { - return s.URL + "/cli/discovery/apis" + return s.URL + "/cli/discovery/apis/bamboo" } func (s *Server) DetailURL(mcpID int) string { diff --git a/test/run_skill_tests.py b/test/run_skill_tests.py index d6a1cec8..adbd7821 100644 --- a/test/run_skill_tests.py +++ b/test/run_skill_tests.py @@ -315,7 +315,6 @@ def run_test(test_case: TestCase) -> dict: def get_reference_file(product: str) -> str: """Get the reference file for a product""" product_to_file = { - 'aiapp': 'aiapp.md', 'aitable': 'aitable.md', 'attendance': 'attendance.md', 'calendar': 'calendar.md', diff --git a/test/skill_tests.md b/test/skill_tests.md index 14158fe1..ad967dfb 100644 --- a/test/skill_tests.md +++ b/test/skill_tests.md @@ -44,7 +44,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然 | `calendar` | `references/products/calendar.md` | 13 | 30 | | `chat` | `references/products/chat.md` | 13 | 31 | | `contact` | `references/products/contact.md` | 7 | 14 | -| `devdoc` | `references/products/simple.md` | 1 | 5 | +| `devdoc` | `references/products/simple.md` | 2 | 7 | | `ding` | `references/products/ding.md` | 2 | 5 | | `report` | `references/products/report.md` | 6 | 26 | | `todo` | `references/products/todo.md` | 6 | 30 | @@ -1007,7 +1007,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然 --- -### devdoc(5 条) +### devdoc(7 条) #### `dws devdoc article search` @@ -1036,6 +1036,18 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然 - Expected: `dws devdoc article search --keyword 免登鉴权 --format json` - Flags: `--keyword` = `免登鉴权` +#### `dws devdoc error diagnose` + +**devdoc_devdoc_error_diagnose_001** +- Prompt: 排查开放平台 requestId 15r6h45w0muec 的调用失败 +- Expected: `dws devdoc error diagnose --request-id 15r6h45w0muec --format json` +- Flags: `--request-id` = `15r6h45w0muec` + +**devdoc_devdoc_error_diagnose_002** +- Prompt: 排查开放平台错误码 33012,错误描述 missing scope +- Expected: `dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json` +- Flags: `--error-code` = `33012`, `--error-message` = `missing scope` + --- ### todo(30 条) diff --git a/test/skill_tests_results.md b/test/skill_tests_results.md index 6b0d7831..4c15b155 100644 --- a/test/skill_tests_results.md +++ b/test/skill_tests_results.md @@ -2,8 +2,8 @@ ## Summary -- **Total Test Cases**: 202 -- **Passed**: 202 +- **Total Test Cases**: 204 +- **Passed**: 204 - **Failed**: 0 - **Pass Rate**: 100.0% @@ -16,7 +16,7 @@ | calendar | 30 | 30 | 0 | 100.0% | | chat | 31 | 31 | 0 | 100.0% | | contact | 14 | 14 | 0 | 100.0% | -| devdoc | 5 | 5 | 0 | 100.0% | +| devdoc | 7 | 7 | 0 | 100.0% | | ding | 5 | 5 | 0 | 100.0% | | report | 26 | 26 | 0 | 100.0% | | todo | 30 | 30 | 0 | 100.0% | @@ -1133,6 +1133,22 @@ - Command path: PASS (devdoc article search) - Flags: PASS (1 flags validated) +**devdoc_devdoc_error_diagnose_001** ✅ PASS + +- Prompt: 排查开放平台 requestId 15r6h45w0muec 的调用失败 +- Expected: `dws devdoc error diagnose --request-id 15r6h45w0muec --format json` +- Skill Reference: references/products/devdoc.md +- Command path: PASS (devdoc error diagnose) +- Flags: PASS (1 flags validated) + +**devdoc_devdoc_error_diagnose_002** ✅ PASS + +- Prompt: 排查开放平台错误码 33012,错误描述 missing scope +- Expected: `dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json` +- Skill Reference: references/products/devdoc.md +- Command path: PASS (devdoc error diagnose) +- Flags: PASS (2 flags validated) + ### ding **ding_ding_message_recall_001** ✅ PASS diff --git a/test/unit/pat_host_owned_signal_test.go b/test/unit/pat_host_owned_signal_test.go index 9123ad0b..095a2f21 100644 --- a/test/unit/pat_host_owned_signal_test.go +++ b/test/unit/pat_host_owned_signal_test.go @@ -21,10 +21,9 @@ import ( // TestHostOwnsPATFlow_OnlySignal is the wire-level guard for the // "custom authorization card" contract: the CLI switches to host-owned -// PAT mode iff the host injects DINGTALK_DWS_AGENTCODE or its compatibility -// alias DWS_DINGTALK_AGENTCODE. DINGTALK_AGENT / claw-type is purely a -// server-side routing tag and must NOT influence the decision, in either -// direction. +// PAT mode iff the host injects DINGTALK_DWS_AGENTCODE. DINGTALK_AGENT / +// claw-type is purely a server-side routing tag and must NOT influence the +// decision, in either direction. // // Regression guard: several earlier drafts conflated the two signals, // causing third-party Agent hosts that only set DINGTALK_DWS_AGENTCODE @@ -34,7 +33,7 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) { cases := []struct { name string agentCode string - compat string + reversed string agentEnv string want bool }{ @@ -51,18 +50,11 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) { want: true, }, { - name: "compat agent code only → host-owned", + name: "reversed draft agent code only → CLI-owned", agentCode: "", - compat: "agt-compat", + reversed: "agt-compat", agentEnv: "", - want: true, - }, - { - name: "primary wins when both env names are set", - agentCode: "agt-primary", - compat: "agt-compat", - agentEnv: "", - want: true, + want: false, }, { name: "agent code + DINGTALK_AGENT=default → host-owned", @@ -100,7 +92,7 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { t.Setenv(authpkg.AgentCodeEnv, tc.agentCode) - t.Setenv(authpkg.AgentCodeEnvCompat, tc.compat) + t.Setenv("DWS_DINGTALK_AGENTCODE", tc.reversed) // DINGTALK_AGENT is set purely to demonstrate that it does NOT // influence the host-owned decision. The literal env name is // used here because the auth package no longer exports a @@ -118,10 +110,6 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) { gotCode, gotSource := authpkg.AgentCodeFromEnv() wantCode := tc.agentCode wantSource := authpkg.AgentCodeEnv - if wantCode == "" { - wantCode = tc.compat - wantSource = authpkg.AgentCodeEnvCompat - } if gotCode != wantCode || gotSource != wantSource { t.Fatalf("AgentCodeFromEnv() = (%q, %q), want (%q, %q)", gotCode, gotSource, wantCode, wantSource) diff --git a/test/unit/service_test.go b/test/unit/service_test.go index d166838c..f2dae81d 100644 --- a/test/unit/service_test.go +++ b/test/unit/service_test.go @@ -210,7 +210,7 @@ func TestDiscoverAllRuntimeKeepsWorkingServersWhenOneFails(t *testing.T) { server := httptest.NewServer(mux) defer server.Close() - 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) { payload := market.ListResponse{ Metadata: market.ListMetadata{Count: 2}, Servers: []market.ServerEnvelope{