Compare commits

...
Author SHA1 Message Date
修雨 d5dbefb27e docs: sync outdated references and add audit-log guide
- architecture.md: rewrite to reflect current plugin-based structure
  (removed market/discovery/ir/generator/compat, added helpers/plugin/audit/pat)
- automation.md: update task routing and repository map to current packages
- command-index.md: fix source citation (internal/plugin replaces removed compat pipeline)
- connect-daemon-service.md: update command path from 'dws devapp robot connect'
  to 'dws dev connect' (7 occurrences + launchd plist args)
- audit-log.md: new doc covering event format, env vars, CLI commands,
  hash chain verification, remote forwarding, and redaction levels
2026-07-06 15:38:57 +08:00
修雨 1c1abf3069 style(helpers): fix gofmt formatting for devapp_connect and connect_daemon
Import ordering and struct field alignment were off since #548,
causing the CI format check to fail on main.
2026-07-06 15:25:24 +08:00
Evilsong7and南凇 043881e0e3 新增企业凭证hook&对应凭证登录检验未通过的报错处理 (#506)
Co-authored-by: 南凇 <tianjinsong.tjs@alibaba-inc.com>
2026-07-06 14:54:27 +08:00
修雨 b63e1b4469 release: cut 1.0.47 — connector supervision + bot-to-bot @-mention (#548) 2026-07-06 00:46:47 +08:00
修雨 bba94c0092 docs(changelog): cut 1.0.46 (#542) 2026-07-01 16:45:06 +08:00
xuanandshangguanxuan.sgx fe4a79283c fix(pat): keep agent code grants aligned (#536)
Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-07-01 10:16:59 +08:00
Ariand修雨 3ee5f13c62 docs: condense Key Services table and document multi-org profiles (#527)
* docs: condense Key Services table and document multi-org profiles

The Key Services section listed a per-service command count and an exhaustive
subcommand token dump plus a long description, which had drifted out of date
and was hard to scan. Condense it (EN + zh) to a lark-cli-style
Service / Command / Capabilities table with a one-line capability per service,
pointing to docs/command-index.md for the full listing.

Also document the multi-organization (profile) capability, which had no README
coverage: a collapsible section placed right after "Custom App mode (CI/CD,
ISV integration)" in Getting Started, covering auth login adding a profile,
profile list / switch, the global --profile one-shot flag, and the agent-
orchestrated cross-org read pattern (writes stay on the current org). Mirrored
in README_zh.md.

CHANGELOG: add a [1.0.45] entry describing the full multi-profile feature
(login / profile management / --profile / backward-forward compatibility /
skill docs) plus the persistence hardening (locking, atomic writes, corruption
recovery, safe legacy mirror, no cross-org token fallback).

* docs(changelog): note --ai-tag default-on (#524) in [1.0.45]

---------

Co-authored-by: 修雨 <huyizhou.hyz@alibaba-inc.com>
2026-06-29 19:20:46 +08:00
e32fa1535c feat(auth): support multi-profile login (#500)
* feat(auth): support multi-profile login

* fix(auth): complete multi-org profile acceptance

* feat(auth): 完成多组织 profile 验收

* docs(auth): 补充多组织 Ralph 验收材料

* feat(auth): 支持 auth switch TUI 切换 profile

* feat(auth): logout 默认清理所有组织

* feat(auth): login 默认新增组织授权

* feat(profile): 使用 profile switch 切换组织

* docs(ralph): 更新 profile switch 验收材料

* fix(profile): 展示全部可切换组织

* feat(profile): support multi-org switch tui

* chore(install): add branch source installer

* fix(profile): keep global profile out of tool params

* feat(profile): support csv multi-profile runtime

* ci: add multi-profile e2e workflow

* ci: run multi-profile e2e on all branches

* docs: document multi-profile e2e ci gate

* docs: remove multi-profile test cases from pr

* ci: harden multi-profile e2e gates

* fix(auth): serialize profiles.json RMW and harden multi-profile persistence

Wrap all profiles.json read-modify-write paths (profile switch/use/remove,
status marking, token save, logout) in the existing dual-layer lock via a new
withProfilesLock helper. Split each writer into a public (locking) entry point
plus a lock-free *Locked variant so the non-reentrant lock is never re-acquired;
the refresh path (oauth_helpers) and the load-path legacy migration now call the
lock-free saver to avoid self-deadlock.

Also: write profiles.json and the token marker via per-write random temp names
(uuid) to stop concurrent writers from corrupting a fixed .tmp file; quarantine
an unparseable profiles.json and rebuild an empty config so the CLI can
self-heal instead of locking out auth reset/logout; make DeleteAllTokenData
proceed even if profiles.json cannot be read; and stop SyncLegacyTokenMirror
from deleting the legacy mirror on a transient keychain read error.

* fix(auth): do not fall back to a different org's legacy token slot

When no explicit --profile is given, LoadTokenDataForProfile resolves the
current/primary profile and reads its per-corp keychain slot. If that slot
read failed, the code silently fell through to the legacy single token slot,
which after any drift between the legacy mirror and the current profile could
belong to a different organization. The command would then run as the wrong
org with no indication to the user.

Reproduction (conceptual):
  - profiles.json currentProfile = corpA
  - corpA's keychain slot is unreadable, legacy single slot still holds corpB
  - any read command (no --profile) silently used corpB's token

Fix: when a profile is resolved but its slot read fails and no --profile was
given, only fall back to the legacy single slot when its CorpID matches the
resolved profile (same org); otherwise return the original error instead of
acting as a different organization. The no-profile legacy path (pre-migration
installs with no resolved profile) is unchanged.

Tests:
  - Covered by the existing internal/auth suite under go test -race; the
    same-org fallback preserves the legacy-mirror case while the cross-org
    case now surfaces the read error.

* feat(skill): document multi-org profile usage and always ship dws-shared

The skills had no guidance on the multi-profile capability, so an agent would
treat the CLI as single-org: when a lookup missed in the current org it would
give up or ask the user instead of searching other logged-in orgs. The multi
skill set also referenced a `dws-shared` prerequisite that was never actually
installed, and the only multi-org hints lived inline in three product skills.

This adds, in source only:
- A "multi-org / profile" section in the mono SKILL.md (concept, commands,
  cross-org rule, aggregation, safety guardrails) plus a decision-tree entry,
  trigger conditions, and a corrected logout danger-table row (logout removes
  all orgs by default; removing the primary silently re-elects a new primary,
  confirm before removing the primary).
- A standalone skills/multi/dingtalk-profile skill mirroring the same content.
- A new skills/multi/dws-shared skill that carries auth, global flags and the
  multi-org rule, so every product skill's PREREQUISITE resolves and all
  read/search skills inherit the cross-org behavior without per-skill edits.
- Cross-org fallback notes on dingtalk-aisearch / chat / contact.

To guarantee the prerequisite actually ships, multi-mode install now force-
includes dws-shared even when --skill / --exclude narrows the set (no-op when
the source has no dws-shared, preserving older layouts).

Tests:
  - internal/app: TestP1SharedAlwaysIncludedWithSkillFilter installs with
    `-s aitable` and asserts dws-shared still lands in the destination;
    TestP1SharedNoopWhenAbsent guards the older-layout no-op.
  - go test -race ./internal/auth/... ./internal/app/... passes.

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
Co-authored-by: qinze <audanye@gmail.com>
2026-06-29 18:27:25 +08:00
修雨 79b8eda3b6 feat(chat): default --ai-tag on so dws-sent messages carry the AI badge
Per req 83667761 (奕皓): messages sent through dws should carry the
「通过AI发送」badge by default, transparently flagging AI/CLI-sent messages.

- `--ai-tag` default flipped false → true on `chat message send` / `reply`, so
  no flag / `--ai-tag` / `--ai-tag=true` all attach clawType (open edition
  `openClaw`); only `--ai-tag=false` omits it (send as the user). The switch name
  is unchanged. reply honors the same default (no longer leaks the wukong
  clawType).
- skill chat.md: concise rule — default-on, pass `--ai-tag=false` to disable.
- tests: default now asserts clawType present; added an `--ai-tag=false` opt-out
  case.
2026-06-29 18:16:20 +08:00
修雨 be80790172 docs(changelog): add [1.0.44] — phantom guard, report contents-file, @file, sheet parity 2026-06-29 10:53:03 +08:00
修雨 2dbbca1ec9 docs(skill): align dws skill references with the real CLI
Every documented command / flag / example now matches `dws <svc> --help`:
- drop phantom commands (attendance class/group/vacation/..., contact label,
  ding message list/receiver-status) that map to undeployed tools.
- fix runtime-failure flags (mail --body→--content, doc/calendar/minutes
  pagination, chat send-by-bot @-flags, wiki member --users).
- route role/duty "who is responsible" queries to `aisearch person --dimension
  duty` instead of the removed `contact label`.
- realign the multi/dingtalk-report skill to entry submit / inbox list / outbox
  list.
2026-06-29 10:53:03 +08:00
修雨 b214c0a06c fix(sheet): wukong parity for range read/update
- accept scalar cells in range update
- flat values projection on read; null clears a cell
- add --hyperlinks flag to range update
2026-06-29 10:53:03 +08:00
修雨 d3087d170b feat(compat): native @file / --contents-file input for structured JSON flags
`dws report entry submit --contents-file <f>` (and `--contents -` stdin) silently
submitted `contents:[null]`. Root cause: the `--contents-file` flag had no
transform, so its value mapped to an unused param while `--contents` stayed empty.

- file_read_json transform + a build-time report hook resolve --contents-file /
  --contents - / @file natively in Go (priority: file > stdin > inline) and
  declare a contents / contents-file one-of group so a file-only invocation is
  no longer rejected at parse time.
- generalizes to @file / @- input for any structured JSON-array flag.
2026-06-29 10:53:03 +08:00
修雨 49637d982e feat(compat): hide phantom override commands from --help (tool-existence guard)
Override leaves whose backing MCP tool isn't actually deployed rendered in
`dws <svc> --help` but failed at invocation with "tool not found" (43 phantom
commands across 391 overrides; attendance declared 38, only 4 deployed).

BuildDynamicCommands now takes an existingTools oracle (CLI slug -> live tool
set from the tools/ cache). A leaf whose tool is missing from its resolved
server's set is marked Hidden; groups left childless collapse. Safety rails:
acts only when a server's tool set is KNOWN and non-empty (cold cache / overlay
/ plugin paths pass nil and no-op, never blanking the tree); serverOverride
leaves resolve against the target server; pipeline leaves are never hidden.

Adds scripts/dev/check-phantom-overrides.py as a publish-time gate, and
phantom_guard_test.go covering hide / cold-cache-keep / serverOverride / pipeline
/ empty-group-collapse.
2026-06-29 10:53:03 +08:00
修雨 4c5f1faeb1 feat: align open CLI with dws-wukong via cedar discovery version code (#509)
Switches the discovery version code bamboo -> cedar and aligns the open edition CLI with dws-wukong across communication (calendar book/acl/attendee, minutes tag, mail folder/template/contact, chat file upload, todo add-attachment, attendance transforms) and structured-office (aitable advperm/view/section/workflow/record, sheet/drive/wiki/doc) domains. Includes output-envelope parity, parse_bool/attendance_class_check_time transforms, --calendar-id support, CHANGELOG 1.0.43 and README command-index refresh. cedar config validated on pre and prod endpoints.
2026-06-26 21:49:38 +08:00
修雨 5833e71751 ci(mirror): localize README for the Gitee mirror (#513)
The Gitee mirror force-pushes main verbatim, so Chinese users saw a
README whose top install commands point at raw.githubusercontent.com
(hard to reach in China) and a coverage badge that fails to render
(the relative .github/badges/coverage.svg can't be served by Gitee —
gitee raw returns a signed, expiring URL with content-type text/plain).

Add a Gitee-only post-process step: build a gitee-main branch on top of
origin/main and rewrite README.md / README_zh.md before pushing —
(1) raw.githubusercontent.com/<repo>/main -> gitee.com/<repo>/raw/main
(2) the coverage badge -> a shields.io static badge whose percentage is
read from the repo's coverage.svg and colored by threshold.

GitHub's README is untouched; only the Gitee copy is rewritten. The
branch is rebuilt from origin/main every run, so it stays a clean
single-commit delta and never drifts.
2026-06-26 16:55:04 +08:00
修雨 0e690fbe4e fix(install): define $LatestUrl in install.ps1 so version resolution works (#512)
Resolve-LatestVersion referenced $LatestUrl (lines 185/197) but the
variable was never defined, so on the default GitHub path both
Invoke-WebRequest calls failed with a null Uri. With
$ErrorActionPreference = "Stop" the script then hit Write-Err and
exit 1 — closing freshly-launched PowerShell windows instantly
(the reported "闪退"). Every user on the default `latest` path was
affected; the Bash installer was unaffected because it inlines the URL.

Define $LatestUrl = "https://github.com/$Repo/releases/latest", mirroring
the Bash installer. Verified end-to-end with pwsh 7.5: the script now
resolves the latest tag, downloads, checksum-verifies and installs.
2026-06-26 16:27:31 +08:00
Ariand修雨 f7e8106a72 docs(devapp): add image-upload recipe + "discovering commands" to dingtalk-dev skill (#508)
* docs(devapp): add image-upload recipe + "discovering commands" to dingtalk-dev skill

The dingtalk-dev skill could set an app/robot icon via --icon-media-id but
never documented where a mediaId comes from: the dev command set has no
upload command, so a mediaId must be fetched from DingTalk's OpenAPI. Agents
had to guess the flow. The per-resource refs also lacked a uniform pointer to
self-discover commands and params, so they leaned on memory instead of --help
/ schema.

recipes.md: new "上传图片拿 mediaId" recipe — credentials get -> gettoken ->
OpenAPI /media/upload (multipart field `media`, type=image) -> robot config /
app update --icon-media-id -> read back. Includes a curl example and notes the
token TTL (~7200s, rate-limited) and a square-icon hint.

references/*.md: append a Chinese "发现命令" block to each of the 10 product
refs (app, credentials, webapp, permission, member, security, robot, version,
event, connect). Each block shows that group's own `--help` plus
`dws schema dev.app.<group>.<method>` (connect uses `dws schema dev.connect`),
mirroring SKILL.md's MUST DO.

Verified end-to-end on a real app (unifiedAppId via dws dev): uploaded a PNG
through /media/upload, set the robot icon with the returned mediaId, and
`robot get` reflected the new iconMediaId with robotStatus=ONLINE. All 10
`--help` targets and the 9 `dws schema dev.app.*` paths + `dws schema
dev.connect` resolve.

* docs(changelog): note dingtalk-dev mediaId recipe + command discovery (#508)

---------

Co-authored-by: 修雨 <47820304+PeterGuy326@users.noreply.github.com>
2026-06-25 23:46:48 +08:00
修雨 87a9b5b9be chore: drop dead fork dev-app cruft (feat/dws-devapp) (#507)
Two leftovers referenced the decommissioned wxianfeng fork branch
`feat/dws-devapp`, both now obsolete after the dev-app work landed on main
(v1.0.42) and the installers were repointed to DingTalk-Real-AI (#505):

  - .github/workflows/auto-dev-release.yml — triggered only on push to
    feat/dws-devapp (a branch that does not exist on this repo, so it never
    fires). Its purpose — auto-publishing fork dev-preview releases for
    install-devapp.sh — is gone now that install-devapp.sh pulls stable
    releases from DingTalk-Real-AI.
  - docs/devapp-yulan-command-routing.md — a 2026-06-05 draft design doc
    pinned to the fork branch and the pre-rename `devapp` command tree,
    superseded by the shipped `dws dev` command set and the rewritten
    docs/devapp-agent-install-guide.md.

After this, the repo has zero `wxianfeng` / `feat/dws-devapp` references.
2026-06-25 21:49:10 +08:00
修雨 97678e6441 fix(devapp): drop the fork — repoint dev installer + docs to DingTalk-Real-AI (#505)
* fix(devapp): point dev installer at DingTalk-Real-AI, drop the fork

install-devapp.sh / .ps1 and the robot quickstart still pulled the dev
binary + dingtalk-dev skill from wxianfeng/dingtalk-workspace-cli's
feat/dws-devapp fork branch. The dev-app work has since landed on main and
shipped in stable v1.0.42 under DingTalk-Real-AI, so the fork dependency is
obsolete.

  - DEVAPP_REPO default: wxianfeng/... → DingTalk-Real-AI/...
  - Bootstrap URLs in headers + quickstart: fork feat/dws-devapp → main.
  - Drop "preview/prerelease" wording — releases are now stable; the
    newest-release resolution still works either way.
  - Quickstart China note now points at the standard install.sh Gitee
    mirror (which carries dws dev in v1.0.42); install-devapp.sh pulls its
    binary from github.com, so a gitee-raw script alone would not help China.

Verified: releases?per_page=1 on DingTalk-Real-AI resolves v1.0.42 and the
darwin/​skills assets are present.

Note: docs/devapp-agent-install-guide.md is separately stale (describes the
old source-build flow + the pre-rename `dws devapp` command) and needs its
own rewrite — left out of this change.

* docs(devapp): rewrite agent install guide for binary install + `dws dev`

The guide was stale on two axes:
  - It described the old source-build flow (clone the fork branch + go/make,
    env vars DEVAPP_REPO_URL / DEVAPP_BRANCH / DEVAPP_SOURCE_DIR), but
    install-devapp.sh now downloads a pre-built binary (curl + tar, no
    git/go/make) from DingTalk-Real-AI.
  - Every command used the pre-rename `dws devapp ...`; the command is now
    `dws dev app ...`.

Rewrite against the real `dws dev` tree (verified from the binary):
  - install: DingTalk-Real-AI binary installer + correct env (DEVAPP_REPO /
    DEVAPP_VERSION / DWS_INSTALL_DIR / DWS_NO_SKILLS) + a China Gitee note.
  - skill name corrected to `dingtalk-dev`.
  - commands: `dws dev app {list,get,create,update,enable,disable,delete,
    credentials,permission,member,robot,security,version,webapp,event}` with
    real flags (--confirm-name, --scope-values, --user-ids, --redirect-urls,
    --version-id/--confirmed-sensitive), async robot create via submit/result,
    version publish gated by check-approval.
2026-06-25 21:04:38 +08:00
修雨 67090ae09f docs: add China (Gitee) install for the standalone Skills installer (#504)
The China-mirror section documented the main install.sh and the npm
package, but not the standalone install-skills.sh — even though that
script already honours DWS_GITEE_REPO and auto-falls back to Gitee when
GitHub is unreachable. The Skills install section only showed the GitHub
URL, so China users (and docs curated from this README) had no China
entry point for skills.

Add a "Skills only (Gitee mirror)" item to both China-mirror sections and
a pointer next to the Skills install command, in README.md and README_zh.md.
2026-06-25 21:04:34 +08:00
修雨 6f042f9167 fix(release): Gitee mirror reads real attach ids + dedups duplicate assets (#502)
The verify-replace mirror listed attachments via /releases/{id}, whose
"assets" array omits the attach id. DELETE /attach_files/{id} was therefore
called with an empty id and silently no-op'd, so a stale asset was never
removed — instead a second (correct) copy was uploaded. Gitee then serves the
OLDER attachment by name, so the stale darwin binaries kept winning and failed
install.sh's checksums.txt verification on macOS (国内 install broken).

Fix:
  - List attachments via the dedicated /attach_files endpoint, which DOES
    return the numeric id needed for deletion.
  - Treat duplicates: collect every attach id carrying a given name; when >1,
    delete them all and upload exactly one fresh, correct file. count==1 still
    does the byte-identical skip / stale-replace; count==0 uploads new.

Self-heals the existing v1.0.42 darwin duplicates on the next mirror run.
2026-06-25 20:10:34 +08:00
meng93 78dd4aaa4b Merge pull request #501 from DingTalk-Real-AI/fix/gitee-mirror-verify-replace
fix(release): Gitee mirror verifies content + replaces stale assets
2026-06-25 17:07:23 +08:00
修雨 088a4d67ae fix(release): Gitee mirror verifies content and replaces stale assets
The v1.0.42 Gitee release served darwin-amd64/arm64 binaries that did NOT
match checksums.txt (the macOS binaries are ad-hoc signed and differed
between the GitHub release and the earlier mirror run), so install.sh's
checksum verification failed for China macOS users. The previous skip-if-name-
present logic could not repair this — it skipped the stale assets.

sync-to-gitee.sh now verifies by content: for each artifact it compares the
sha256 of the asset already on Gitee against the local file (downloaded from
the GitHub release), and

  • skips it when byte-identical,
  • deletes + re-uploads it when present but stale,
  • uploads it when missing,

bringing the Gitee release into byte-for-byte agreement with the GitHub
release that checksums.txt describes. Re-running the Sync-release-to-gitee
workflow now self-heals a mismatched mirror.

bash -n + sha256 helper validated locally.
2026-06-25 16:54:46 +08:00
修雨 81f5245c8a Merge pull request #499 from DingTalk-Real-AI/fix/gitee-release-sync-idempotent
fix(release): idempotent Gitee mirror + standalone repair workflow
2026-06-25 16:33:20 +08:00
修雨 c4946c3eaf fix(release): make Gitee mirror idempotent + add standalone repair workflow
The v1.0.42 Release job hit timeout-minutes: 30 mid-upload while mirroring
release assets to Gitee, so the Gitee release ended up missing
dws-windows-arm64.zip and checksums.txt. Root cause + fixes:

- sync-to-gitee.sh now skips assets already attached to the Gitee release,
  so a re-run only uploads what is missing (instead of re-uploading every
  artifact and creating duplicates). It no longer fails when everything is
  already present.
- New workflow sync-release-to-gitee.yml (workflow_dispatch, version input)
  mirrors a published GitHub release's assets to Gitee on its own — it
  downloads the assets from the GitHub release and runs the idempotent sync,
  without running GoReleaser or touching the GitHub release (no outage). Use
  it to repair an incomplete Gitee mirror.
- Bump the Release job timeout 30 -> 60 so a full Gitee upload has room.

bash -n + YAML validated.
2026-06-25 16:21:34 +08:00
275 changed files with 29732 additions and 8440 deletions
-103
View File
@@ -1,103 +0,0 @@
name: Auto Dev Release
# 推到 dev 分支 → 跑测试 → 通过才自动出一个 dev 预览 release。
# install-devapp.sh / .ps1 取 fork 最新 release,所以发完安装链接即最新。
#
# 设计要点:
# - 自包含:测试 + 算版本 + 打 tag + 发 release 全在一个 job,不依赖 tag 触发
# release.yml(GITHUB_TOKEN 推的 tag 本来也不会触发别的 workflow)。
# - 质量门:测试这一步失败,后面发布步骤就不会跑。
# - 不含 npm publish,所以自动发布天然是绿的。
on:
push:
branches:
- feat/dws-devapp
# 串行:两次 push 不会同时算版本号撞车
concurrency:
group: auto-dev-release
cancel-in-progress: false
permissions:
contents: write
jobs:
test-and-release:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
# ---- 质量门:测试不过就不发 ----
- name: Build
run: make build
- name: Test
run: go test -race -count=1 -timeout=5m ./cmd/... ./internal/...
# ---- 算下一个 dev 版本号并打 tag ----
- name: Compute next dev version
id: ver
run: |
set -eu
LATEST=$(git tag -l 'v*-dev.*' --sort=-v:refname | head -1 || true)
if [ -z "$LATEST" ]; then
BASE="v1.0.39"; N=0
else
BASE=$(printf '%s' "$LATEST" | sed -E 's/-dev\.[0-9]+$//')
N=$(printf '%s' "$LATEST" | sed -E 's/.*-dev\.([0-9]+)$/\1/')
fi
NEW="${BASE}-dev.$((N + 1))"
echo "version=$NEW" >> "$GITHUB_OUTPUT"
echo "next dev release: $NEW (prev: ${LATEST:-none})"
- name: Create and push tag
run: |
set -eu
V="${{ steps.ver.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "$V"
git push origin "$V"
# ---- 自己发 release(不靠 tag 触发 release.yml)----
- name: Install rcodesign (ad-hoc sign darwin binaries from Linux)
run: |
set -eu
RCS_VERSION="0.27.0"
curl -fsSL -o /tmp/rcodesign.tar.gz \
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz"
mkdir -p /tmp/rcodesign
tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rcodesign --strip-components=1
sudo install -m 0755 /tmp/rcodesign/rcodesign /usr/local/bin/rcodesign
rcodesign --version
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Post-release packaging
run: ./scripts/release/post-goreleaser.sh
env:
DWS_PACKAGE_VERSION: ${{ steps.ver.outputs.version }}
- name: Upload dws-skills.zip to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ steps.ver.outputs.version }}" dist/dws-skills.zip --clobber
+35 -4
View File
@@ -31,14 +31,45 @@ jobs:
with:
fetch-depth: 0
- name: Push main + tags to Gitee
- name: Push main + tags to Gitee (with README localization)
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'
# Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。
# GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。
git checkout -B gitee-main origin/main
git config user.email "actions@github.com"
git config user.name "github-actions[bot]"
# 1) 安装命令本地化:raw.githubusercontent → gitee raw(国内可达)。
for f in README.md README_zh.md; do
[ -f "$f" ] || continue
sed -i "s#raw.githubusercontent.com/${GITEE_REPO}/main#gitee.com/${GITEE_REPO}/raw/main#g" "$f"
done
# 2) coverage 徽章:仓库内相对路径 svg 在 Gitee 渲染不出来(gitee raw 对 svg
# 返回需签名、会过期的 URL,且 content-type 为 text/plain)。改成 shields.io
# 静态徽章——数值取自仓库 coverage.svg,颜色按覆盖率阈值。
SVG=".github/badges/coverage.svg"
if [ -f "$SVG" ]; then
PCT="$(grep -oE '[0-9]+(\.[0-9]+)?%' "$SVG" | head -1)"
NUM="${PCT%\%}"; INT="${NUM%.*}"
if [ "${INT:-0}" -ge 80 ]; then C=brightgreen; elif [ "${INT:-0}" -ge 60 ]; then C=yellow; else C=red; fi
BADGE="https://img.shields.io/badge/coverage-${NUM}%25-${C}"
for f in README.md README_zh.md; do
[ -f "$f" ] || continue
sed -i "s#\.github/badges/coverage\.svg#${BADGE}#g" "$f"
done
fi
git add README.md README_zh.md 2>/dev/null || true
git commit -m "docs(gitee): localize install commands + coverage badge for Gitee mirror" || true
# 镜像对齐(force:Gitee 始终跟随 GitHub + Gitee 专属 README 本地化)
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
git push --force --tags "$REMOTE"
echo "✅ 已镜像 main + tags 到 Gitee ${GITEE_REPO}"
echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}"
+54
View File
@@ -0,0 +1,54 @@
name: Multi Profile E2E
on:
pull_request:
push:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: multi-profile-e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
multi-profile-e2e:
name: Multi Profile E2E
runs-on: ubuntu-latest
timeout-minutes: 15
env:
MULTI_PROFILE_E2E_LOG: .tmp-bin/multi-profile-e2e.log
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run isolated multi-profile chain
shell: bash
run: |
set -o pipefail
mkdir -p .tmp-bin
bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir | tee "$MULTI_PROFILE_E2E_LOG"
{
echo "### Multi Profile E2E"
echo "- Command: \`bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir\`"
echo "- Scope: isolated auth/profile storage, profile switch/use, one-shot profile override, CSV multi-profile aggregation, legacy migration"
echo "- Result: passed"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload debug artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: multi-profile-e2e-debug
path: |
.tmp-bin/multi-profile-e2e.*/out
.tmp-bin/multi-profile-e2e.log
if-no-files-found: ignore
retention-days: 3
+7 -1
View File
@@ -12,7 +12,10 @@ permissions:
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 30
# 60 (not 30): mirroring every release asset to Gitee is slow; 30 min cut the
# Gitee step off mid-upload on the v1.0.42 release. The Gitee step is now also
# idempotent (re-runs only upload missing assets).
timeout-minutes: 60
steps:
- name: Check out repository
@@ -28,6 +31,9 @@ jobs:
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Multi Profile E2E
run: bash scripts/dev/test-multi-profile-e2e.sh
- name: Install rcodesign (ad-hoc sign darwin binaries from Linux)
run: |
set -eu
@@ -0,0 +1,49 @@
name: Sync release to Gitee
# Manually mirror a published GitHub release's assets to the matching Gitee
# release. Use this to repair a release whose Gitee mirror is incomplete (e.g.
# the Release job timed out mid-upload). It runs ONLY the idempotent Gitee sync
# step — it does not run GoReleaser and does not touch the GitHub release, so
# there is no release outage. The sync script skips assets already on Gitee, so
# this only uploads what is missing.
on:
workflow_dispatch:
inputs:
version:
description: "Release tag to mirror to Gitee (e.g. v1.0.42)"
required: true
type: string
permissions:
contents: read
jobs:
sync-gitee:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Download GitHub release assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
mkdir -p dist
gh release download "${{ inputs.version }}" \
--repo "${{ github.repository }}" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
ls -la dist
- name: Mirror release to Gitee (China)
# Idempotent: uploads only assets not already present on the Gitee release.
run: ./scripts/release/sync-to-gitee.sh
env:
VERSION: ${{ inputs.version }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
+2
View File
@@ -42,3 +42,5 @@ dws.zip
# 功能测试运行产物
results.jsonl
test/dev_functional/results.jsonl
.vercel
.env*
+101
View File
@@ -6,6 +6,107 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.47] - 2026-07-05
This release adds **connector supervision & health monitoring** (`dev connect list/status/restart/stop`) and fixes **bot-to-bot @-mention** delivery end-to-end.
### Added
- **`dev connect list`** — PM2-style colored table enumerating all local connectors with state (healthy / degraded / down / not_running), PID, channel, and uptime.
- **`dev connect status`** — panel view with heartbeat, last recv timestamp, session webhook age, and `--json` for external monitoring.
- **`dev connect restart`** — restarts a daemon via persisted `daemon-state.json` (unified-app-id credential fetch, no local secret storage).
- **`dev connect stop`** — graceful SIGTERM shutdown releasing the single-instance lock and Stream connection.
- **Health watchdog** — background goroutine writes `heartbeat.json`; `status`/`list` derive state from heartbeat freshness + process liveness + pid-reuse detection.
- **`--alwayson` flag** — opt-in auto-restart: supervisor relaunches the worker on crash (requires `--daemon`).
- **`--notify-staff-id`** — state-change notifications (start / stop / crash) sent as DingTalk messages to the specified staffId.
- **`--unified-app-id` credential flow for `dev connect`** — fetches clientId/clientSecret at startup via `dev app credentials get`, keeping secrets off the command line and out of `daemon-state.json`.
- **API-sent file download** (`feat(connect): download API-sent files via storage v2 API`) — file messages sent via `dws chat message send --msg-type file --dentry-id --space-id` are now downloaded by the connector through the storage v2 `getDownloadInfo` API (dentryId + spaceId → presigned URL → local temp file), so file-based Q&A works regardless of how the file was sent.
- **`--at-open-dingtalk-ids` for `chat message send-by-bot`** — @-mention bots or cross-org users by openDingTalkId in group messages.
### Fixed
- **Bot-to-bot @-mention send side** — `atOpendingtalkIds` (the server's lowercase spelling) is now used instead of the camelCase `atOpenDingTalkIds` which was silently ignored. The unnecessary `openDingTalkId → userId` reverse lookup (always failed for bots) is removed; the id is forwarded verbatim.
- **Bot-to-bot @-mention receive side** — `interactiveCard` messages (how DingTalk delivers a bot @-mentioning another bot) are now parsed: `extractInteractiveCardText` flattens `cardContent[].children[].value` leaves and strips the leading @-mention by leaf boundary. The `emotion/reply` reaction (which 500s on bot-sent cards) is skipped for `interactiveCard` turns.
- **Markdown/richText body extraction** — `extractCallbackText` gains a `cardContent` fallback so structured-text messages are no longer silently dropped.
- **Send-by-bot @ chip rendering** — `<@id>` placeholders in the markdown body are rewritten to `@id` for both userIds and openDingTalkIds so the mention chip renders in all cases.
- **Connector retry on transient network errors** — `sendBySession` retries on transient failures instead of dropping the reply.
- **Orphan worker cleanup & watchdog deadlock** — stale workers from a crashed supervisor are detected and cleaned; a channel-capacity fix prevents the watchdog from blocking.
- **Idle connector false-down** — heartbeat ticker now advances `updatedUnix` so a connector with no inbound traffic is not marked degraded.
- **FD limit check** — `checkFDLimit` split into platform files for Windows cross-compilation.
- **Default agent timeout removed** — no timeout by default (was incorrectly defaulting to a low value).
- **keepAlive shortened to 30 µs** — aligns with Stream SDK expectations; adds `ulimit` check for multi-agent stability.
## [1.0.46] - 2026-07-01
### Fixed
- **PAT agentCode grants no longer split from follow-up command checks** (`internal/auth/agent_code_detect.go`, `internal/app/runner.go`, `internal/pat/chmod_test.go`) — explicit `DINGTALK_DWS_AGENTCODE` declarations are now forwarded verbatim as the common cross-host contract, and unknown hosts no longer synthesize `custom` into `x-dingtalk-dws-agent-code` / `x-dws-agent-instance-id`. `pat chmod --agentCode` remains the highest-priority grant target and still wins over the env fallback.
## [1.0.45] - 2026-06-29
This release adds **multi-organization (profile) support** (#500): `dws` can stay logged in to several DingTalk organizations at once and switch between them, while staying fully backward/forward compatible with the previous single-org token. A profile is one logged-in organization (corp); the current profile decides which org a command runs against. The release also hardens the new credential store for concurrency and corruption recovery, documents the capability in both the mono and multi skill sets, and flips `--ai-tag` on by default so messages sent through `dws` carry the DingTalk 「通过AI发送」 badge (#524).
### Added
- **Multi-organization login & `profile` management** (`internal/auth/profiles.go`, `internal/app/profile_command.go`) — `dws auth login` against a new organization adds a profile (the first login becomes the primary); `dws profile list` shows logged-in orgs with primary / current markers, status and validity; `dws profile switch <name|corpId|->` persistently switches the default org (`-` toggles back to the previous one, no-arg opens a TUI selector on a terminal); `dws profile use` is an alias of `switch`. `dws auth status [--profile <name>]` reports a specific profile. Credentials are stored per organization in keychain slots keyed by corpId (`auth-token:<corpId>`), with a plaintext `profiles.json` registry holding only metadata and the primary/current/previous pointers (no tokens).
- **Global `--profile <name|corpId>` flag** — run a single command against a specific organization without changing the default (one-shot; does not move currentProfile). Cross-org reads are orchestrated by the agent (list profiles → query each with `--profile` → merge); there is intentionally no built-in `--all-orgs`.
- **Backward / forward compatibility with the legacy single token slot** — a pre-existing single-slot token is migrated into `auth-token:<corpId>` and marked primary on first multi-profile use; the current (or primary) profile's token is mirrored back into the legacy slot so older binaries and the embedded host keep working. `profiles.json` is additive and ignored by older versions.
- **`dingtalk-profile` and `dws-shared` skills + multi-org documentation** (`skills/`) — a standalone `dingtalk-profile` skill plus a new `dws-shared` skill that carries auth, global flags and the multi-org rule, so every multi-mode product skill's PREREQUISITE resolves and all read/search skills inherit cross-org behavior. The mono skill gains a "multi-org / profile" section, trigger conditions, a decision-tree entry and a corrected logout danger note. Multi-mode install now always ships `dws-shared` even when `--skill` / `--exclude` narrows the set.
### Changed
- **`--ai-tag` now defaults on — DingTalk 「通过AI发送」 badge for dws-sent messages** (`internal/helpers/chat.go`, #524) — `chat message send` / `reply` flip the `--ai-tag` default from false to true, attaching the AI `clawType` by default so messages sent through `dws` (and by AI agents) transparently carry the 「通过AI发送」 badge; pass `--ai-tag=false` to send as the user with no badge.
- **Concurrency-safe, self-healing `profiles.json`** (`internal/auth/profiles.go`, `internal/auth/token.go`) — every read-modify-write on `profiles.json` and the legacy mirror is serialized under the existing dual-layer (process + cross-process) lock, split into public (locking) entry points and lock-free `*Locked` variants so the non-reentrant lock is never re-acquired (the refresh path and the load-path migration use the lock-free savers). `profiles.json` and the token marker are written via per-write random temp names + atomic rename so concurrent writers can no longer corrupt a fixed `.tmp`. An unparseable `profiles.json` is quarantined (`*.corrupt-*`) and rebuilt empty so the CLI self-heals; `auth reset` / `logout` proceed even when it cannot be read and sweep the quarantined files.
### Fixed
- **No silent fallback to a different org's token** (`internal/auth/token.go`) — when the resolved current/primary profile's keychain slot fails to read and no `--profile` was given, the loader now only falls back to the legacy single slot if it belongs to the same organization; otherwise it surfaces the error instead of acting as a different org.
- **Legacy mirror no longer wiped on a transient keychain read error** (`internal/auth/profiles.go`) — `SyncLegacyTokenMirror` distinguishes "token genuinely absent" from "keychain momentarily unreadable" and keeps the existing mirror in the latter case, so a host app's login state is not dropped by a transient failure.
## [1.0.44] - 2026-06-28
This release hardens the dynamic-command surface and finishes the dws-wukong parity pass for structured input. Phantom override commands whose backing MCP tool isn't deployed are hidden from `--help`; `report entry submit` reads `--contents-file` / stdin natively; structured JSON flags accept `@file` / `@-`; and `sheet range update` / `range read` now accept the same plain shapes wukong does (scalar cells, flat `values`, null-clears-cell, a `--hyperlinks` flag). On the wukong01 sandbox this lifts the full open-edition cli_to_mcp pass rate from 77.6% to 95.5% (sheet 28.5% → 99.8%, report → 100%); the remaining failures are account / org / out-of-scope, not CLI defects.
### Added
- **`dingtalk-dev` skill: image-upload → `mediaId` recipe + per-resource command discovery** (`skills/multi/dingtalk-dev/references/`) — documents how to obtain a `mediaId` for app / robot icons via the DingTalk OpenAPI (`credentials get` → `gettoken` → `/media/upload?type=image` → `--icon-media-id` → read back), since the dev command set has no upload command; and adds a "discovering commands" block to all 10 product refs pointing at each group's `--help` and `dws schema dev.app.<group>.<method>` (`dws schema dev.connect` for connect), so agents inspect commands instead of relying on memory.
- **`report entry submit --contents-file <path>` / `--contents -` (stdin) read natively** (#514, `internal/compat/report_hooks.go`) — the envelope publishes `entry submit` (MCP `create_report`) with a `--contents` (json_parse, required) flag plus a sibling `--contents-file` that had no transform / mapsTo, so a `--contents-file`-only submit silently sent `contents: [null]` and the report failed (only inline `--contents` worked, which is why `report create` succeeded while `report entry submit --contents-file` did not). A build-time compat hook now resolves the file / stdin natively (10MB cap, UTF-8 check, wukong priority `--contents-file` > `--contents -` > inline) and relaxes the individual `required` on `--contents` into a `contents` / `contents-file` one-of group. No discovery-config change needed.
- **`@file` / `@-` input for structured JSON flags** (`internal/compat/transform.go`) — `json_parse` / `json_parse_strict` now expand a leading `@` before parsing (`@-` reads stdin, `@<path>` reads a file), so long / complex payloads (many records, big 2D cell ranges, filter criteria) skip shell-quoting hell. A JSON / YAML value never starts with `@`, so the sentinel is unambiguous; the error hint that already advertised `@path/to/file.json` is now truthful. `sheet`'s shared `sheetParseJSONFlag` routes through `cli.ResolveInputSource` so the same support reaches `--values` / `--criteria` / `--sort-keys`.
- **`sheet range update --hyperlinks`** (`internal/helpers/sheet.go`) — a wukong-shaped 2D hyperlink grid (`[[{"type":"path","link":"...","text":"..."}]]`) overlaid onto the cells grid as each cell's `hyperlink` field; `--values` or `--hyperlinks` is now required (at least one).
### Changed
- **Phantom override commands hidden from `--help`** (#515, `internal/compat/dynamic_commands.go`) — override leaves whose backing MCP tool isn't actually deployed used to render in `dws <svc> --help` and then fail at invocation with *tool not found*. A tool-existence guard now hides them, and command groups left empty by the hidden leaves are collapsed, so `--help` reflects only invokable commands. Skill references are re-aligned to the real CLI surface (phantom commands dropped; role/duty "who is responsible" queries routed to `aisearch`, not `contact`).
- **`sheet range update` accepts scalar cells; `sheet range read` projects a flat `values`; `--values '[[null]]'` clears a cell** (`internal/helpers/sheet.go`, `internal/helpers/sheet_cell_validation.go`) — dws-wukong parity. `range update` (set_cell_range) auto-wraps a scalar cell (string / number / bool) into `{type:text,text:"..."}` instead of rejecting it, so the plain `[["姓名","部门"]]` shape that `sheet append` and wukong's update_range accept now works; a null cell clears content (matching wukong); `{}` still means keep-original. `range read` (get_cell_infos) now also exposes a flat `values` 2D array next to the rich `cells` payload, matching wukong's get_range shape without dropping cell styles.
- **report skill aligned to `entry submit` / `inbox list` / `outbox list`** (`skills/multi/dingtalk-report/`, `skills/mono/references/intent-guide.md`) — the multi skill tree was two versions behind and still taught the deprecated flat aliases (`report create` / `sent` / `list` / `detail` / `stats`) and falsely claimed `report inbox` was unimplemented. Re-aligned to the canonical resource.verb commands consistently (old aliases still execute with a stderr deprecation notice).
## [1.0.43] - 2026-06-26
This release aligns the open edition's CLI surface with **dws-wukong** across the communication domain (chat / mail / minutes / todo / calendar / contact / aisearch / live / report / ding) and the structured-office domain (aitable / sheet / drive / wiki / doc), and switches the discovery version code from `bamboo` to `cedar` so the aligned command tree is served from its own discovery config.
### Added
- **`calendar book get|search` and `calendar acl list`** (cedar discovery overrides) — query a specific calendar (primary via `--id primary`), fuzzy-search calendars by name, and list a calendar's access-control entries. Maps to the calendar MCP `get_calendar` / `search_calendar` / `list_acls` tools.
- **`calendar attendee list|add|delete`** (`internal/helpers/calendar_commands.go`) — manage event participants under the wukong-aligned `attendee` naming (equivalent to the legacy `participant` group; calls `get/add/remove_calendar_participant`).
- **`minutes tag list` and `minutes tag query --tag-id`** — list a user's AI-minutes tags and query minutes by tag (`query_user_tag_list` / `query_minutes_by_tag_id`).
- **`minutes list mine|shared|all`** (`internal/helpers/minutes_commands.go`) — list own / shared / all minutes with renamed output fields.
- **`mail folder create|update|delete`, `mail template create|list|get|update|delete`, `mail contact create|list|update|batch-delete`, and `mail message list`** — full mail folder / message-template / contact CRUD plus folder-scoped message listing.
- **`chat file upload`** (`internal/helpers/chat_file.go`) — upload a local file (init/PUT/commit) or a remote URL to a conversation's file space.
- **`todo task add-attachment`** (`internal/helpers/todo_commands.go`) — attach a local file to a todo (multi-step upload).
- **aitable extensions** (`internal/helpers/aitable_extra.go`) — advanced permission / roles, view sub-commands (lock / duplicate / frozen-cols / row-height / fill-color-rule / card / timebar), section node management, workflow enable/disable, record `upsert` / `share-url` / `history-list` / primary-doc, and field search-options. Helper tools route to the hardcoded `aitable-helper` supplement endpoint.
- **sheet, drive, wiki, doc helper coverage** synced from dws-wukong (`internal/helpers/sheet.go`, `drive.go`, `wiki.go`, `doc.go`).
### Changed
- **Discovery version code `bamboo` → `cedar`** (`internal/market/registry.go`; `discoveryAPIPath = "/cli/discovery/apis/cedar"`) — version codes step by first letter (bamboo → cedar → …); `cedar` carries the dws-wukong alignment. Older binaries keep reading `bamboo`, so the change is isolated to this release line. All test/mock/generator fixtures updated to the cedar path.
- **CLI output envelope aligned with wukong for cross-edition parity** (`internal/app/runner.go`, `internal/compat/registry.go`) — dry-run prints a `DRY-RUN Arguments:` line, successful results carry `success: true`, missing-required-flag wording is unified to `missing required flag(s): --x`, and OutputTransform applies to the response content layer.
- **New flag transforms** (`internal/compat/transform.go`) — `parse_bool` (explicit boolean strings so `--flag false` is honoured) and `attendance_class_check_time` (`HH:mm` → UTC+8 milliseconds for shift check-times).
- **`--calendar-id` accepted on calendar event / participant / room / attachment commands** so calendars other than the primary can be targeted.
### Fixed
- **Client-side validation** for calendar recurrence completeness and attendance schedule / class / group inputs, surfacing input errors before they reach the server.
## [1.0.42] - 2026-06-25
This release rounds out `dws dev connect` — bridge a DingTalk robot to your local AI (Claude Code / Codex / opencode / Qoder / …): a generic `custom` channel for any headless CLI tool, in-chat `/new` / `/clear` session commands aligned to each agent's real session op, and a fix for long opencode turns being cut at 30 seconds.
+54 -23
View File
@@ -135,6 +135,14 @@ 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.
**3. Skills only (Gitee mirror):**
```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-skills.sh | sh
```
> With `DWS_GITEE_REPO` set, `install-skills.sh` resolves the version and skills package from Gitee; it also auto-falls back to the Gitee mirror when GitHub is unreachable.
## Upgrade
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
@@ -225,6 +233,22 @@ Credentials are securely persisted after first login (Keychain). Subsequent runs
</details>
<details>
<summary><strong>Multiple organizations (profiles)</strong></summary>
`dws` can stay logged in to several DingTalk organizations at once. Each organization is one **profile**; the current profile decides which org a command runs against (credentials are stored per organization).
```bash
dws auth login # log in to another org → adds a profile (first login becomes the primary)
dws profile list # list logged-in orgs (primary / current marker, status)
dws profile switch <name|corpId> # switch the default org (use - to toggle back to the previous one)
dws --profile <name|corpId> contact user search --query "..." # run one command against a specific org, without changing the default
```
Cross-org reads are orchestrated by the agent rather than a built-in `--all-orgs`: list the profiles, run the query per org with `--profile`, then merge. Writes default to the current org only — confirm the target org before writing across orgs.
</details>
<details>
<summary><strong>Migrate auth between Linux sandboxes</strong></summary>
@@ -309,6 +333,8 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
```
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
>
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
**Switching or re-installing with `dws skill setup`:**
@@ -500,9 +526,14 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
`dws dev connect` bridges a DingTalk robot to a local AI CLI (Claude Code / Codex / opencode / Qoder / Gemini, or any tool via `--agent-cmd`): @-mention the bot in a chat and it answers using your local agent, keeping per-conversation multi-turn memory.
```bash
dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <secret>
dws dev connect --channel auto --unified-app-id <unifiedAppId>
```
> `--unified-app-id` resolves `clientSecret` at runtime via `dev app credentials get`,
> so the secret never appears in argv (`ps` / journald / shell history). The
> legacy `--robot-client-id <id> --robot-client-secret <secret>` still works but
> the CLI will warn you.
In-chat **session commands** (send the bare command as the whole message — no agent turn, no tokens):
| Command | Effect |
@@ -514,29 +545,29 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
## Key Services
| Service | Command | Commands | Subcommands | Description |
|---------|---------|:--------:|-------------|-------------|
| 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` | 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` | 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 the DingTalk Open Platform documentation and diagnose 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 |
| Service | Command | Capabilities |
|---------|---------|--------------|
| Contact | `contact` | Look up users by name / mobile / job-number, departments, labels & roles, roster profiles & dismissals |
| Chat / IM | `chat` (`im`) | Send / reply / search messages, group & member management, bot & webhook messaging, reactions, recall |
| Calendar | `calendar` | Events CRUD, attendees, meeting rooms, free/busy & time suggestions |
| Todo | `todo` | Create / list / update / complete tasks and comments |
| Approval | `oa` | Approve / reject / revoke / transfer; query pending / initiated / CC instances and forms |
| Attendance | `attendance` | Clock-in records, shifts, summaries, group rules (read-only) |
| Ding | `ding` | Send / recall DING messages |
| Report | `report` | Create / submit logs, inbox & outbox, templates, statistics |
| AI Tables | `aitable` | Bases / tables / records / fields / views, permissions & roles, automation, charts & dashboards, import / export |
| Doc | `doc` | Search / read / write docs, block-level editing, comments, permissions, media, up / download |
| Drive | `drive` | List / search / download, folders, upload, copy / move / rename, permissions |
| Minutes | `minutes` | AI meeting notes: list, summary / keywords / transcription / todos, mind map, speakers, tags |
| Mail | `mail` | Mailboxes, KQL search, read / send, drafts, folders, templates, contacts |
| Sheet | `sheet` | Online spreadsheets: worksheet & range read / write, filters, conditional format, images, CSV |
| Wiki | `wiki` | Knowledge bases: spaces, members, node tree, docs & files |
| DevDoc | `devdoc` | Search the Open Platform docs and diagnose API errors |
| AI Search | `aisearch` | Enterprise people search by name / dept / role / duty / supervisor / phone / job-number |
| Live | `live` | List my live streams |
| Raw API | `api` | Call any DingTalk OpenAPI directly, with managed app-level token |
> **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.
> Full command listing with usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for any service's 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.
+48 -22
View File
@@ -135,6 +135,14 @@ npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
> npmmirror 会自动同步公网 npm 的公开包,国内可直接使用。
**3. 单独安装 Skills(Gitee 镜像):**
```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-skills.sh | sh
```
> 同样设置 `DWS_GITEE_REPO`,`install-skills.sh` 会从 Gitee 解析版本和 skills 包;GitHub 不可达时也会自动回退到 Gitee 镜像。
## 升级
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
@@ -225,6 +233,22 @@ dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
</details>
<details>
<summary><strong>多组织(profile)</strong></summary>
`dws` 可以同时登录多个钉钉组织。一个组织就是一个 **profile**,当前 profile 决定本次命令操作哪个组织(凭证按组织分别存储)。
```bash
dws auth login # 再登录一个组织 → 新增一个 profile(首次登录的为主组织)
dws profile list # 列出已登录组织(主 / 当前标记、状态)
dws profile switch <名称|corpId> # 切换默认组织(用 - 切回上一个)
dws --profile <名称|corpId> contact user search --query "..." # 单次对指定组织执行,不改默认组织
```
跨组织读取由 agent 编排,而非内置 `--all-orgs`:先 `dws profile list` 拿到组织,再对每个组织带 `--profile` 各查一遍,然后合并。写操作默认只在当前组织进行——跨组织写之前先确认目标组织。
</details>
<details>
<summary><strong>沙箱间迁移登录态(Linux)</strong></summary>
@@ -306,6 +330,8 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
```
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
>
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
**用 `dws skill setup` 切换或重装:**
@@ -510,29 +536,29 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
## 核心服务
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|------|------|:------:|--------|------|
| 通讯录 | `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` | 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` | 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` | 搜索钉钉开放平台文档并排查 API 调用错误 |
| AI 搜问 | `aisearch` | 3 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
| 服务 | 命令 | 能力 |
|------|------|------|
| 通讯录 | `contact` | 按姓名 / 手机号 / 工号查人,部门、角色标签、花名册与离职 |
| 群聊 | `chat`(`im`)| 发送 / 回复 / 搜索消息,群与成员管理,机器人与 Webhook 发消息,表情反应,撤回 |
| 日历 | `calendar` | 日程 CRUD、参与者、会议室、闲忙与时间建议 |
| 待办 | `todo` | 创建 / 列表 / 修改 / 完成待办及评论 |
| 审批 | `oa` | 同意 / 拒绝 / 撤销 / 转交,查待办 / 已发起 / 抄送及表单 |
| 考勤 | `attendance` | 打卡记录、排班、考勤摘要、考勤组规则(只读) |
| DING | `ding` | 发送 / 撤回 DING 消息 |
| 日志 | `report` | 创建 / 提交日志,收发件箱,模版,统计 |
| AI 表格 | `aitable` | Base / 数据表 / 记录 / 字段 / 视图,权限与角色,自动化,图表与仪表盘,导入导出 |
| 文档 | `doc` | 搜索 / 读写文档,块级编辑,评论,权限,媒体,上传 / 下载 |
| 钉盘 | `drive` | 列表 / 搜索 / 下载,文件夹,上传,复制 / 移动 / 重命名,权限 |
| AI 听记 | `minutes` | 听记列表、摘要 / 关键词 / 转写 / 待办、思维导图、发言人、标签 |
| 邮箱 | `mail` | 邮箱、KQL 搜索、读 / 发、草稿、文件夹、模版、联系人 |
| 在线电子表格 | `sheet` | 在线表格:工作表与区域读写、筛选、条件格式、图片、CSV |
| 知识库 | `wiki` | 知识库:空间、成员、节点树、文档与文件 |
| 开发者文档 | `devdoc` | 搜索开放平台文档并排查 API 错误 |
| AI 搜问 | `aisearch` | 企业人员搜索:按姓名 / 部门 / 角色 / 职责 / 上下级 / 手机号 / 工号 |
| 直播 | `live` | 查看我的直播列表 |
| Raw API | `api` | 直接调用任意钉钉 OpenAPI,自动管理应用级 Token |
> **18 个产品,331 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
> 完整命令清单(带描述与使用场景):[`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` 产品。
+4 -4
View File
@@ -8,7 +8,7 @@ warehouse. This page is the integration contract.
| Header | Meaning | Granularity |
|--------|---------|-------------|
| `x-dingtalk-dws-agent-code` | which agent host (claudecode / codex / qoder / cursor / custom …) | channel |
| `x-dingtalk-dws-agent-code` | which agent host (claudecode / codex / qoder / cursor / custom if explicitly declared …) | 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) | — |
@@ -26,7 +26,7 @@ clients send no `agent_code` / instance id — treat their absence as
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.
5. **T4 — unresolved:** unknown host sends no agent_code. Never guessed.
## Declaring your agent (recommended — the only fully-general path)
@@ -55,8 +55,8 @@ MCP server config example (JSON-style hosts):
`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.
Use a stable slug. Values declared via `DINGTALK_DWS_AGENTCODE` are forwarded
verbatim so PAT grants and follow-up command checks use the same key.
## Trust & limitations — READ THIS
+31 -16
View File
@@ -4,23 +4,38 @@
## High-Level Flow
1. `internal/market` fetches the registry and server metadata.
2. `internal/discovery` resolves runtime server capabilities and caches results.
3. `internal/ir` normalizes discovery output into one canonical tool catalog.
4. `internal/cli` and `internal/app` mount that catalog into the public Cobra command tree.
5. `internal/transport` executes MCP JSON-RPC calls and `internal/output` formats responses.
1. `cmd` is the CLI entrypoint, invoking `internal/app` to build the root Cobra command tree.
2. `internal/app` wires static utility commands (`auth`, `audit`, `schema`, `completion`) and dynamically loads product commands via `internal/plugin`.
3. `internal/helpers` contains the main command handlers for all product surfaces (`dev`, `chat`, `calendar`, `contact`, `aitable`, etc.).
4. `internal/executor` and `internal/transport` execute MCP JSON-RPC calls; `internal/output` formats responses.
5. `internal/auth` manages login state, PAT tokens, and agent-code detection.
## Repository Structure
- `cmd`: CLI entrypoint
- `internal/app`: root command wiring and static utility commands
- `internal/discovery`, `internal/market`, `internal/transport`: runtime discovery and execution
- `internal/ir`: canonical intermediate representation for discovered tools
- `internal/generator`: docs, schema, and skill generation pipeline
- `internal/compat`, `internal/helpers`: legacy-compatible overlays and helper commands
- `skills/`: bundled agent skills source and generated skill docs
- `test/`: CLI, compatibility, integration, contract, and script tests
## Public Repository Contract
This repository ships source, docs, tests, packaging templates, and install scripts. Generated or release-only artifacts are produced by repository scripts and are not required to exist in a clean checkout unless explicitly committed as part of a release workflow.
- `internal/app`: root command wiring, static utility commands, and plugin loading
- `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
- `internal/plugin`: plugin-based dynamic command loader
- `internal/cli`: catalog types and endpoint loader (static endpoint mode)
- `internal/executor`: invocation dispatch and result handling
- `internal/transport`: MCP HTTP client and request signing
- `internal/auth`: login, token management, agent-code detection, identity
- `internal/audit`: user operation audit log (JSONL, hash chain, forwarding)
- `internal/errors`: structured error model with categories and hints
- `internal/keychain`: OS keychain integration for credential storage
- `internal/security`: endpoint allowlist and domain trust
- `internal/safety`: runtime safety checks (confirm prompts, dry-run guards)
- `internal/cobracmd`: shared Cobra command builders
- `internal/pat`: PAT (Personal Access Token) authorization flow
- `internal/output`: response formatting (json, table, raw, pretty)
- `internal/logging`: structured logging and argument sanitization
- `internal/tui`: terminal UI helpers
- `internal/recovery`: panic recovery and graceful degradation
- `pkg/configmeta`: environment variable registry and documentation
- `pkg/config`: configuration constants and paths
- `pkg/edition`: edition detection (oss vs enterprise)
- `pkg/mcptypes`: MCP protocol type definitions
- `skills/`: bundled agent skills (mono/ and multi/ layouts)
- `test/`: CLI, integration, contract, unit, and skill E2E tests
- `scripts/`: install scripts, policy checks, and CI helpers
- `envelope/`: pre-built discovery payloads for offline use
+227
View File
@@ -0,0 +1,227 @@
# 审计日志(Audit Log)
DWS 自动记录每次 MCP HTTP 调用的审计事件,用于合规追溯和安全审查。默认启用,无需额外配置。
## 功能特性
- **自动记录**:每次命令执行产生一条 JSONL 审计事件
- **按天轮转**:日志文件按日期分割(`audit-YYYYMMDD.jsonl`),默认留存 90 天
- **防篡改**:L1 哈希链(sha256),每条事件链接前一条的 hash,可验证完整性
- **远端转发**:支持 POST 到外部 SIEM 或审计平台
- **三级脱敏**:转发时可按 none/hashed/minimal 脱敏敏感字段
- **CLI 命令**:内置 `dws audit tail/export/verify` 查看和验证
## 环境变量
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `DWS_AUDIT` | 启用 | 设 `0`/`false`/`off` 关闭审计 |
| `DWS_AUDIT_DIR` | `~/.dws/audit` | 审计日志目录 |
| `DWS_AUDIT_RETENTION_DAYS` | `90` | 日志留存天数 |
| `DWS_AUDIT_FORWARD_URL` | (空) | 远端转发 URL(POST JSON) |
| `DWS_AUDIT_FORWARD_TOKEN` | (空) | 远端转发 Bearer Token |
| `DWS_AUDIT_FORWARD_REDACT` | `none` | 转发脱敏级别:`none`/`hashed`/`minimal` |
## 审计事件格式
每条事件是一行 JSON,字段如下:
```json
{
"ts": "2026-07-06T10:59:06+08:00",
"execution_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"agent_id": "agent-xxx",
"actor": {
"user_id": "525018",
"name": "胡奕舟",
"corp_id": "ding8196cd9a2b2405da24f2f5cc6abecb85",
"corp_name": "钉钉"
},
"product": "calendar",
"command": "list_events",
"endpoint": "https://api.dingtalk.com/v1.0/calendar/users/xxx/calendars/primary/events",
"params_summary": "maxResults=20",
"result": "success",
"error_category": "",
"error_reason": "",
"duration_ms": 234,
"cli_version": "1.0.47",
"os": "darwin",
"arch": "arm64",
"prev_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}
```
**字段说明**:
- `ts`:事件时间戳(RFC3339)
- `execution_id`:本次命令执行的唯一 ID
- `agent_id`:Agent 标识(若有)
- `actor`:执行者信息(从登录态获取)
- `product`:调用的产品(如 `calendar`、`chat`、`contact`)
- `command`:调用的命令(如 `list_events`、`send_message`)
- `endpoint`:实际请求的 API 端点(已脱敏)
- `params_summary`:参数摘要(已脱敏)
- `result`:`success` 或 `error`
- `error_category` / `error_reason`:错误分类和原因(成功时为空)
- `duration_ms`:命令执行耗时(毫秒)
- `prev_hash` / `hash`:哈希链字段,用于防篡改验证
## CLI 命令
### 查看最近日志
```bash
# 查看最近 20 条审计事件(默认)
dws audit tail
# 查看最近 50 条
dws audit tail -n 50
```
输出示例:
```
2026-07-06 10:59:06 calendar list_events user=525018 result=success 234ms
2026-07-06 10:58:42 chat send_message user=525018 result=success 156ms
2026-07-06 10:57:15 contact search user=525018 result=success 89ms
```
### 导出日志
```bash
# 导出最近 7 天的 JSONL
dws audit export --since 2026-06-29 --until 2026-07-06 --format jsonl > audit-7d.jsonl
# 导出为 CSV(方便 Excel 打开)
dws audit export --since 2026-07-01 --format csv > audit-july.csv
```
### 验证哈希链
```bash
# 验证当前最新日志文件的哈希链完整性
dws audit verify
# 验证指定文件
dws audit verify --file ~/.dws/audit/audit-20260706.jsonl
```
输出:
```
✓ 哈希链完整:234 条事件全部校验通过
```
或:
```
✗ 哈希链断裂:第 156 条事件的 hash 不匹配
```
## 哈希链防篡改
每条事件的 `hash` 字段由以下公式计算:
```
hash = sha256(prev_hash + event_json_without_hash_fields)
```
- 首条事件的 `prev_hash` 为空字符串
- 后续事件的 `prev_hash` = 前一条的 `hash`
- 任何对历史事件的篡改都会导致后续所有 hash 失效
**验证流程**:
1. 读取日志文件,逐行解析
2. 对每条事件,移除 `prev_hash` 和 `hash` 字段,重新序列化
3. 用前一条的 hash + 当前事件 JSON 计算新 hash
4. 对比计算结果与文件中记录的 hash
5. 全部匹配 = 完整;某条不匹配 = 被篡改
## 远端转发
设置 `DWS_AUDIT_FORWARD_URL` 后,每条审计事件会异步 POST 到指定端点:
```bash
export DWS_AUDIT_FORWARD_URL=https://siem.example.com/ingest/audit
export DWS_AUDIT_FORWARD_TOKEN=your-bearer-token # 可选
```
**请求格式**:
```http
POST /ingest/audit HTTP/1.1
Host: siem.example.com
Authorization: Bearer your-bearer-token
Content-Type: application/json
{"ts":"2026-07-06T10:59:06+08:00","product":"calendar",...}
```
**超时与重试**:3 秒超时,失败不阻塞命令执行(best-effort),不自动重试。
## 脱敏分级
转发时可通过 `DWS_AUDIT_FORWARD_REDACT` 控制脱敏级别:
| 级别 | 行为 | 适用场景 |
|------|------|----------|
| `none`(默认) | 原样转发,不脱敏 | 内部审计平台 |
| `hashed` | actor.name 哈希化,params_summary 脱敏 | 跨部门共享 |
| `minimal` | 仅保留 ts/product/command/result/duration_ms,移除 actor/endpoint/params | 对外合规报告 |
**示例**:
```bash
# 最小化脱敏(仅保留元数据)
export DWS_AUDIT_FORWARD_REDACT=minimal
export DWS_AUDIT_FORWARD_URL=https://compliance.example.com/audit
```
## 常见问题
### Q: 审计日志占多少磁盘?
典型场景(每天 100 条命令)约 50KB/天,90 天约 4.5MB。日志文件是 JSONL 纯文本,gzip 压缩后约 1/5。
### Q: 关闭审计会影响性能吗?
设置 `DWS_AUDIT=0` 后,审计模块不初始化,零开销。默认启用时,每条事件写入耗时 <1ms(异步磁盘 IO)。
### Q: 哈希链断了怎么办?
可能原因:
1. 手动编辑过日志文件
2. 磁盘损坏
3. 并发写入导致顺序错乱(罕见)
**处理**:
- 备份当前日志
- 用 `dws audit verify` 定位断裂位置
- 从断裂点之后的事件可继续验证(前缀已不可信)
### Q: 如何清理旧日志?
自动清理:`DWS_AUDIT_RETENTION_DAYS=90`(默认),超过 90 天的文件在下次启动时 best-effort 删除。
手动清理:
```bash
# 删除 2026 年 6 月之前的日志
rm ~/.dws/audit/audit-202605*.jsonl
```
## 技术实现
- **核心包**:`internal/audit`
- **集成点**:`internal/app/runner.go` 的 `executeInvocation` 方法(defer 调用 `emitAudit`)
- **身份获取**:从 `auth.LoadTokenData` 读取当前登录用户
- **参数脱敏**:调用 `logging.SanitizeArguments`(与现有日志脱敏逻辑一致)
## 相关文档
- [环境变量参考](./reference.md)
- [架构概览](./architecture.md)
- [自动化与脚本](./automation.md)
+26 -25
View File
@@ -13,40 +13,44 @@ repository root while preserving repo-local guidance for automation.
## Project Snapshot
- `dws` is a Go-based DingTalk Workspace CLI and MCP runtime bridge.
- One internal Tool IR drives canonical CLI, schema, docs, skills, and snapshots.
- Compatibility and helper surfaces are overlays, not the canonical truth.
- Product commands are loaded dynamically via `internal/plugin` from bundled descriptors.
- Command handlers live in `internal/helpers`; runtime execution flows through `internal/executor` and `internal/transport`.
## Repository Map
- `cmd`: public CLI entrypoint
- `internal/app`: root command wiring and command tree mount points
- `internal/discovery`, `internal/market`, `internal/transport`: runtime discovery and MCP transport
- `internal/generator`: CLI/schema/docs/skills generation pipeline
- `internal/compat`, `internal/helpers`: legacy-compatible aliases and helper commands
- `internal/app`: root command wiring, static utility commands, plugin loading
- `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
- `internal/plugin`: plugin-based dynamic command loader
- `internal/cli`: catalog types and static endpoint loader
- `internal/executor`: invocation dispatch and result handling
- `internal/transport`: MCP HTTP client and request signing
- `internal/auth`: login, token management, agent-code detection
- `internal/audit`: user operation audit log
- `internal/errors`: structured error model with categories and hints
- `internal/keychain`: OS keychain integration for credential storage
- `internal/security`: endpoint allowlist and domain trust
- `internal/pat`: PAT (Personal Access Token) authorization flow
- `docs/`: public architecture and reference docs
- `hack/`: developer-only helper commands not shipped as public binaries
- `scripts/`: build, test, lint, packaging, and policy checks
- `test/`: integration, contract, compatibility, and script validation suites
- `test/`: CLI, integration, contract, unit, and skill E2E test suites
## Task Routing
- Add or fix a command path: start from `internal/app` and the related module under `internal/*`
- Discovery or protocol issues: inspect `internal/discovery`, `internal/market`, `internal/transport`
- Generated output drift: inspect `internal/generator` and run drift checks
- Legacy behavior mismatch: inspect `internal/compat` and `test/cli_compat`
- Failure or degraded mode: inspect `internal/discovery`, `internal/errors`
- Add or fix a command path: start from `internal/helpers` (handler implementations) or `internal/app` (command tree wiring)
- Protocol or transport issues: inspect `internal/transport`
- Auth or login issues: inspect `internal/auth`, `internal/pat`, `internal/keychain`
- Error message or category issues: inspect `internal/errors`
- Audit log issues: inspect `internal/audit`
- Plugin loading or command surface: inspect `internal/plugin`
- Failure or degraded mode: inspect `internal/errors`, `internal/recovery`
## Generated Artifacts
## Policy Checks
Prefer editing source logic instead of generated files directly.
When command surface or plugin descriptors change, run:
- Generated-heavy paths:
- `docs/generated/`
- `skills/generated/`
- `test/golden/generated_outputs/`
- When generator or command surface changes, run:
- `./scripts/policy/check-generated-drift.sh`
- `./scripts/policy/check-command-surface.sh --strict`
- `./scripts/policy/check-command-surface.sh --strict`
- `./scripts/policy/check-open-source-assets.sh`
## Common Commands
@@ -55,9 +59,6 @@ make build
make test
make lint
./scripts/dev/ci-local.sh
./scripts/policy/check-generated-drift.sh
./scripts/policy/check-command-surface.sh --strict
./scripts/policy/check-open-source-assets.sh
git diff --check
```
+2 -3
View File
@@ -2,12 +2,11 @@
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
- **Source**: `dws-wukong/envelope/channel/open/pre/config.json`
- **Products**: 13
- **Total commands**: 160
- **Generated from**: `internal/compat.BuildDynamicCommands` rendering of the pre config — the same code path the CLI uses at runtime.
- **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
> Auto-generated. Edit `pre/config.json`, not this file.
> Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
## Global flags
+40 -20
View File
@@ -1,20 +1,28 @@
# Running the connector as a 7x24 service
`dws devapp robot connect` keeps a DingTalk robot wired to a local agent over a
`dws dev connect` keeps a DingTalk robot wired to a local agent over a
Stream long-connection. By default it runs in the foreground and dies when the
terminal closes. For an unattended "digital employee" you have two options.
> **Security**: prefer `--unified-app-id <uappid>` over
> `--robot-client-id/--robot-client-secret`. With `--unified-app-id` the CLI
> resolves clientId/clientSecret at runtime through `dev app credentials get`,
> so the secret never appears in `ps` / journald / shell history. Pasting
> `--robot-client-secret` onto argv lets any local user read your AppSecret
> with `ps -ef`; the CLI will warn you when you do that.
## Option A: built-in daemon (recommended for a quick start)
```bash
# Detach into a background supervisor that restarts the connector if it crashes.
dws devapp robot connect --daemon \
dws dev connect --daemon \
--channel claudecode \
--robot-client-id <clientId> --robot-client-secret <clientSecret>
--unified-app-id <unifiedAppId>
# Inspect / stop it.
dws devapp robot connect status --robot-client-id <clientId>
dws devapp robot connect stop --robot-client-id <clientId>
# Inspect / stop / restart it (locate the daemon by unifiedAppId).
dws dev connect status --unified-app-id <unifiedAppId>
dws dev connect stop --unified-app-id <unifiedAppId>
dws dev connect restart --unified-app-id <unifiedAppId>
```
- The parent prints the daemon pid and the log path, then exits.
@@ -23,9 +31,9 @@ dws devapp robot connect stop --robot-client-id <clientId>
10 consecutive fast failures) when it exits abnormally.
- The single-instance lock (one connector per robot per machine) is reused, so a
duplicate daemon refuses to start.
- Logs go to `~/.dws/connect/<clientId>/daemon.log` with size-based rotation
- Logs go to `~/.dws/connect/<key>/daemon.log` with size-based rotation
(5 MB x 2 backups), and the pid file lives at
`~/.dws/connect/<clientId>/daemon.pid`.
`~/.dws/connect/<key>/daemon.pid`.
- The daemon does NOT survive a reboot. For that, use Option B.
> Windows: `--daemon` is not supported (no `setsid` / POSIX signal stop). Use a
@@ -39,7 +47,7 @@ restart it. This is the most robust way to get boot-time auto-start.
### macOS — launchd
Save as `~/Library/LaunchAgents/com.dingtalk.dws.connect.plist`, edit the paths
and credentials, then `launchctl load -w <path>`.
and `REPLACE_UNIFIED_APP_ID`, then `launchctl load -w <path>`.
```xml
<?xml version="1.0" encoding="UTF-8"?>
@@ -52,15 +60,12 @@ and credentials, then `launchctl load -w <path>`.
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/dws</string>
<string>devapp</string>
<string>robot</string>
<string>dev</string>
<string>connect</string>
<string>--channel</string>
<string>claudecode</string>
<string>--robot-client-id</string>
<string>REPLACE_CLIENT_ID</string>
<string>--robot-client-secret</string>
<string>REPLACE_CLIENT_SECRET</string>
<string>--unified-app-id</string>
<string>REPLACE_UNIFIED_APP_ID</string>
</array>
<key>RunAtLoad</key>
<true/>
@@ -86,8 +91,8 @@ itself relies on the single-instance lock to avoid duplicates.
### Linux — systemd (user service)
Save as `~/.config/systemd/user/dws-connect.service`, edit paths/credentials,
then:
Save as `~/.config/systemd/user/dws-connect.service`, edit paths and
`REPLACE_UNIFIED_APP_ID`, then:
```bash
systemctl --user daemon-reload
@@ -104,10 +109,9 @@ Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/dws devapp robot connect \
ExecStart=/usr/local/bin/dws dev connect \
--channel claudecode \
--robot-client-id REPLACE_CLIENT_ID \
--robot-client-secret REPLACE_CLIENT_SECRET
--unified-app-id REPLACE_UNIFIED_APP_ID
Restart=always
RestartSec=5
# Optional hardening:
@@ -121,6 +125,22 @@ WantedBy=default.target
`Restart=always` + `RestartSec` gives crash recovery; systemd captures stdout/
stderr into the journal (`journalctl --user -u dws-connect -f`).
## Legacy: passing clientId/clientSecret directly (not recommended)
If you truly must pass credentials on the command line (e.g. one-off local
debugging without a unifiedAppId), the CLI still accepts
`--robot-client-id <id> --robot-client-secret <secret>` and will print a
security warning to stderr. This form:
- exposes `clientSecret` to every user on the box via `ps -ef`;
- gets baked into launchd `ProgramArguments` / systemd `ExecStart`, which
makes rotation harder;
- means `dws dev connect restart` cannot re-fetch credentials — you
must re-run the full command yourself.
Prefer `--unified-app-id`. Only fall back to the pair when you understand the
trade-off.
## Which to choose
- Just need it to outlive the terminal and self-heal on crash → `--daemon`.
+3 -3
View File
@@ -108,9 +108,9 @@
- **期望(分步)**:
1. 新建:`robot submit --name <应用名> --robot-name 小助手 --desc <功能> --dry-run` → `--yes`(拿 taskId)→ 按 `intervalSeconds` 轮询 `robot result --task-id <taskId>`,只有 `SUCCESS` 才用返回 `robotCode/clientId/clientSecret`(敏感)。
2. 现有应用:`robot get` 若 `robotStatus=UNCONFIGURED` → `robot config --unified-app-id <id> --name ... --mode STREAM --dry-run` → `--yes`(upsert 首次即创建)→ 回读 `robot get` 看 `robotStatus=ONLINE` → 需要时 `robot enable`(停用 `robot disable`)。
3. 建联:`dev connect --channel auto --robot-client-id x --robot-client-secret y --dry-run` 看出参 `cli` 字段做依赖预检;正式 connect 是前台长驻进程,对话里跑要后台运行并告诉用户怎么停,或引导自己开终端。
- **通过判据**:走异步 submit/result(同步建号已下线),轮询到 SUCCESS 再用凭证;未配置时走 config 不是 enable;config 是 upsert;写后回读 `robotStatus`;建联先 dry-run 预检、处理好长驻/缺凭证(先 submit/result 建号)。
- **易错点**:找「同步一次建好」的命令;WAITING 就用凭证;robot info not exist 时去 enable;前台直接起 connect 卡住对话。
3. 建联:`dev connect --channel auto --unified-app-id UAID --dry-run` 看出参 `cli` 字段做依赖预检;正式 connect 是前台长驻进程,对话里跑要后台运行并告诉用户怎么停,或引导自己开终端。
- **通过判据**:走异步 submit/result(同步建号已下线),轮询到 SUCCESS 再用凭证;未配置时走 config 不是 enable;config 是 upsert;写后回读 `robotStatus`;建联先 dry-run 预检、处理好长驻/缺凭证(先 submit/result 建号);默认用 `--unified-app-id` 建联而不是把 clientSecret 明文拼进命令行(避免被 `ps` 拉到)。
- **易错点**:找「同步一次建好」的命令;WAITING 就用凭证;robot info not exist 时去 enable;前台直接起 connect 卡住对话;把 clientSecret 直接怼到命令行上。
### C9. 事件订阅与上游错误排查
- **用户说**:「让这个应用订阅『群成员入群』事件,订阅完看下当前订阅了哪些,再把它取消掉;对了我之前发版本报了个 errcode 62012,这是啥意思?」
+113 -97
View File
@@ -1,4 +1,4 @@
# DevApp 一键安装与 Agent 接入指南
# dws dev 一键安装与 Agent 接入指南
面向希望用 Codex、Claude、Cursor 等开发 Agent 管理钉钉开放平台应用的开发者。
@@ -6,45 +6,44 @@
## 一键安装
当前 DevApp 能力在 `feat/dws-devapp` 预览分支上。要安装这个分支里的最新能力,请使用 DevApp 专用安装脚本:
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-dev` skill,**只需要 curl + tar,不需要 git / go / make**。
### macOS / Linux
```bash
curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.sh | sh
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
```
### Windows(PowerShell)
```powershell
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
```
这个脚本会:
1. 拉取 `wxianfeng/dingtalk-workspace-cli` 的 `feat/dws-devapp` 分支。
2. 使用本地源码构建 `dws`。
3. 安装 `dws` 到默认目录 `~/.local/bin`。
4. 安装 Agent Skill 到本机已检测到的 Agent 目录,只安装通用 `dws` 和 DevApp 专用 `dws-devapp` 两个 skill。
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
2. 安装 `dws` 到默认目录 `~/.local/bin`。
3. 从 Release 的 skills 包里安装 `dingtalk-dev` skill 到本机已检测到的 Agent 目录。
> 预览分支安装需要本机已有 `git`、`go` 和 `make`。Go 版本要求以仓库 `go.mod` 为准。
如果 DevApp 能力已经发布到正式 Release,可以改用正式安装命令:
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
```
Windows PowerShell 正式安装命令:
```powershell
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
```
安装脚本支持这些环境变量:
支持这些环境变量(全部可选):
| 变量 | 说明 |
|---|---|
| `DEVAPP_REPO_URL` | 覆盖源码仓库地址,默认 `https://github.com/wxianfeng/dingtalk-workspace-cli.git` |
| `DEVAPP_BRANCH` | 覆盖安装分支,默认 `feat/dws-devapp` |
| `DEVAPP_SOURCE_DIR` | 使用已有源码目录安装,跳过 clone |
| `DEVAPP_KEEP_SOURCE=1` | 保留临时源码目录,便于调试 |
| `DEVAPP_SKIP_SKILL_SETUP=1` | 跳过自动安装 `dws` 与 `dws-devapp` skill |
| `DEVAPP_SKILL_NAME` | 覆盖 DevApp skill 安装名称,默认 `dws-devapp` |
| `DWS_INSTALL_DIR` | 传给底层 `scripts/install.sh`,覆盖 `dws` 安装目录 |
| `DWS_SKILL_MODE` | 传给底层 `scripts/install.sh`,选择 `mono` 或 `multi` |
| `DEVAPP_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-dev` skill 安装 |
> `dws dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
### 国内加速
`dws dev` 已在正式版里,国内用户直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
```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
```
## 安装后验证
@@ -54,13 +53,13 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
dws version
```
确认 DevApp 命令存在:
确认 `dws dev app` 命令存在:
```bash
dws devapp --help --format json
dws dev app --help --format json
```
如果能看到 `list`、`get`、`create`、`permission`、`robot`、`security`、`version` 等能力,说明 DevApp 已安装成功。
如果能看到 `list`、`get`、`create`、`update`、`permission`、`member`、`robot`、`security`、`version`、`webapp`、`event`、`credentials` 等子命令,说明已安装成功。
确认登录状态:
@@ -77,14 +76,18 @@ dws auth login
登录完成后读取应用列表:
```bash
dws devapp list --format json
dws dev app list --format json
```
## DevApp 是什么
## dws dev 是什么
DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装后,开发者和 Agent 可以用统一命令管理企业内部应用,而不需要反复进入开发者后台页面。
`dws dev` 是钉钉开放平台开发者命令组,三块能力:
它让 Agent 可以完成这些工作:
- `dws dev app` — 开放平台企业内部应用的全生命周期管理(创建、配置、权限、成员、安全、机器人、版本发布、事件订阅)。
- `dws dev connect` — 把现成机器人接到当前本地 agent(起 Stream 连接做本地转发,不建号、不产生审批工单)。
- `dws dev doc` — 开放平台开发文档搜索。
安装后,开发者和 Agent 可以用统一命令管理企业内部应用,而不需要反复进入开发者后台页面。它让 Agent 可以完成这些工作:
- 查询、创建、更新、启用、停用、删除开放平台应用。
- 查询应用凭证,读取 `clientId` / `appKey`,敏感凭证走专用命令。
@@ -92,12 +95,12 @@ DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装
- 查询、申请、移除权限点。
- 管理应用成员。
- 配置安全项,包括 IP 白名单、登录重定向 URL、端内免登地址。
- 创建、查询、更新、启用、停用机器人。
- 异步创建机器人、配置/启停现有机器人。
- 创建版本、发起发布、查询审批和发布状态。
## 给 Agent 使用
安装完成后,可以直接让 Agent 操作 DevApp。
安装完成后,可以直接让 Agent 操作 `dws dev`。
示例:
@@ -114,7 +117,7 @@ DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装
```
```text
帮我发布这个应用版本,先检查发布前置条件。
帮我发布这个应用版本,先预检是否需要审批。
```
Agent 写操作必须遵循:
@@ -127,34 +130,30 @@ Agent 写操作必须遵循:
## 第一个写操作
推荐用机器人配置作为 smoke test。先 dry-run:
推荐用机器人配置作为 smoke test。建号是异步的,分两步。
提交建号任务(记下返回的 `taskId`):
```bash
dws devapp robot config \
--unified-app-id <unifiedAppId> \
--name "告警机器人" \
--brief "告警通知" \
dws dev app robot submit \
--name "告警助手" \
--robot-name "告警机器人" \
--desc "处理告警通知和事件回调" \
--dry-run \
--format json
```
确认预览无误后执行:
确认预览无误后去掉 `--dry-run`、加 `--yes` 执行,再用返回的 `taskId` 查结果,直到 `status` 变成 `SUCCESS`:
```bash
dws devapp robot config \
--unified-app-id <unifiedAppId> \
--name "告警机器人" \
--brief "告警通知" \
--desc "处理告警通知和事件回调" \
--yes \
--format json
dws dev app robot result --task-id <taskId> --format json
```
回读验证:
对**已有机器人**的应用,改配置/启停用 `robot config` / `robot enable` / `robot disable`:
```bash
dws devapp robot get --unified-app-id <unifiedAppId> --format json
dws dev app robot get --unified-app-id <unifiedAppId> --format json
dws dev app robot config --unified-app-id <unifiedAppId> --name "新机器人名称" --dry-run --format json
```
## 常用命令
@@ -162,19 +161,21 @@ dws devapp robot get --unified-app-id <unifiedAppId> --format json
### 应用管理
```bash
dws devapp list --format json
dws devapp get --unified-app-id <unifiedAppId> --format json
dws devapp create --name "考勤应用" --dry-run --format json
dws devapp update --unified-app-id <unifiedAppId> --name "新应用名" --dry-run --format json
dws devapp inactive --unified-app-id <unifiedAppId> --dry-run --format json
dws devapp active --unified-app-id <unifiedAppId> --dry-run --format json
dws devapp delete --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app list --format json
dws dev app get --unified-app-id <unifiedAppId> --format json
dws dev app create --name "考勤应用" --dry-run --format json
dws dev app update --unified-app-id <unifiedAppId> --name "新应用名" --dry-run --format json
dws dev app enable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app disable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app delete --unified-app-id <unifiedAppId> --confirm-name "<应用名>" --format json
```
> 删除不可逆,需要用 `--confirm-name` 传入应用名做二次确认。
### 凭证查询
```bash
dws devapp credentials get --unified-app-id <unifiedAppId> --format json
dws dev app credentials get --unified-app-id <unifiedAppId> --format json
```
凭证输出可能包含敏感字段,不要把完整结果写入文档、日志或长期记忆。
@@ -182,68 +183,82 @@ dws devapp credentials get --unified-app-id <unifiedAppId> --format json
### 权限点管理
```bash
dws devapp permission list --unified-app-id <unifiedAppId> --format json
dws devapp permission add --unified-app-id <unifiedAppId> --permissions Contact.User.mobile --dry-run --format json
dws devapp permission remove --unified-app-id <unifiedAppId> --permissions Contact.User.mobile --dry-run --format json
dws dev app permission list --unified-app-id <unifiedAppId> --format json
dws dev app permission add --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
dws dev app permission remove --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
```
权限申请和移除只使用 `scopeValue`,不要传 API 名或权限分组名。
### 机器人配置
### 机器人能力
```bash
dws devapp robot get --unified-app-id <unifiedAppId> --format json
dws devapp robot config --unified-app-id <unifiedAppId> --name "机器人名称" --dry-run --format json
dws devapp robot enable --unified-app-id <unifiedAppId> --dry-run --format json
dws devapp robot disable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app robot get --unified-app-id <unifiedAppId> --format json
dws dev app robot submit --name "<智能体名>" --robot-name "<机器人名>" --desc "<描述>" --dry-run --format json
dws dev app robot result --task-id <taskId> --format json
dws dev app robot config --unified-app-id <unifiedAppId> --name "机器人名称" --dry-run --format json
dws dev app robot enable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app robot disable --unified-app-id <unifiedAppId> --dry-run --format json
```
### 成员与安全
```bash
dws devapp member list --unified-app-id <unifiedAppId> --format json
dws devapp member add --unified-app-id <unifiedAppId> --users <userId> --dry-run --format json
dws devapp member remove --unified-app-id <unifiedAppId> --users <userId> --dry-run --format json
dws devapp security config --unified-app-id <unifiedAppId> --redirect-url <url> --dry-run --format json
dws devapp security config --unified-app-id <unifiedAppId> --ip-whitelist <ip> --dry-run --format json
dws dev app member list --unified-app-id <unifiedAppId> --format json
dws dev app member add --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
dws dev app member remove --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
dws dev app security config --unified-app-id <unifiedAppId> --redirect-urls <url> --dry-run --format json
dws dev app security config --unified-app-id <unifiedAppId> --ip-whitelist <ip> --dry-run --format json
```
### 网页应用与事件
```bash
dws dev app webapp get --unified-app-id <unifiedAppId> --format json
dws dev app webapp config --unified-app-id <unifiedAppId> --homepage-url <url> --dry-run --format json
dws dev app event list --unified-app-id <unifiedAppId> --format json
dws dev app event subscribe --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app event unsubscribe --unified-app-id <unifiedAppId> --dry-run --format json
```
### 版本发布
```bash
dws devapp version list --unified-app-id <unifiedAppId> --format json
dws devapp version list --unified-app-id <unifiedAppId> --cursor <nextCursor> --format json
dws devapp version create --unified-app-id <unifiedAppId> --dry-run --format json
dws devapp version publish --unified-app-id <unifiedAppId> --version-id <versionId> --dry-run --format json
dws devapp version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
dws dev app version list --unified-app-id <unifiedAppId> --format json
dws dev app version create --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app version check-approval --unified-app-id <unifiedAppId> --version-id <versionId> --format json
dws dev app version publish --unified-app-id <unifiedAppId> --version-id <versionId> --dry-run --format json
dws dev app version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
```
> 发布前先用 `version check-approval` 预检是否需要审批。含高敏权限的版本,`publish` 需加 `--confirmed-sensitive`。
## 安全边界
DevApp 的目标不是绕过开发者后台权限,而是让 CLI、MCP 和 Web 后台保持一致。
`dws dev` 的目标不是绕过开发者后台权限,而是让 CLI、MCP 和 Web 后台保持一致。
默认安全策略:
- 写操作先 dry-run。
- 删除、停用、发布必须由用户确认。
- 删除、停用、发布必须由用户确认(删除还需 `--confirm-name` 二次确认)。
- Agent 不接收用户手动传入的 access token、cookie、`clientSecret`、`appSecret`。
- 应用定位优先使用 `agentId`、`unifiedAppId`、`appKey`。
- 应用定位优先使用 `unifiedAppId`、`agentId`、`appKey`。
- 对权限点申请、成员变更、安全配置、版本发布记录操作结果,便于审计和回滚。
## 排障
### `dws devapp` 不存在
### `dws dev app` 不存在
先确认安装的是预览分支源码,而不是正式 Release:
先确认装上的是带 `dws dev` 的版本:
```bash
dws version
dws devapp --help --format json
dws dev app --help --format json
```
如果正式 Release 尚未包含 DevApp,请重新执行本文的一键源码安装命令。
如果命令缺失,重新执行本文的一键安装命令(或标准 `install.sh`)升级到最新正式版。
### `dws devapp list` 失败
### `dws dev app list` 失败
优先检查登录态:
@@ -254,11 +269,13 @@ dws auth login
然后确认当前账号能访问目标企业,并且当前用户在目标企业内。
### 提示"当前用户没有开发者身份"
创建应用需要开放平台开发者权限。请企业管理员在钉钉开放平台(open-dev.dingtalk.com)的「权限管理」中把你的账号添加为开发者,然后重试。
### 页面能操作,但 CLI 或 MCP 提示无权限
通常说明 CLI/MCP 后端鉴权和 Web 后台权限没有对齐。
先确认当前用户是否满足以下任一条件:
通常说明 CLI/MCP 后端鉴权和 Web 后台权限没有对齐。先确认当前用户是否满足以下任一条件:
- 应用 owner。
- 应用管理员。
@@ -270,18 +287,17 @@ dws auth login
先查当前机器人状态:
```bash
dws devapp robot get --unified-app-id <unifiedAppId> --format json
dws dev app robot get --unified-app-id <unifiedAppId> --format json
```
如果机器人不存在,使用 `robot config` 创建或配置。
如果机器人已存在,继续用 `robot config` 修改配置,或用 `robot enable` 重新启用。
如果机器人不存在,用 `robot submit` 异步创建;如果已存在,用 `robot config` 修改,或用 `robot enable` 重新启用。
## 页面文案建议
用于产品页顶部:
```text
Install DevApp in one command.
Install dws dev in one command.
Let your coding agents manage DingTalk Open Platform apps from the terminal:
create apps, configure robots, apply permissions, manage security settings,
@@ -291,7 +307,7 @@ and publish versions with dry-run safety built in.
中文版本:
```text
一行命令接入 DevApp。
一行命令接入 dws dev。
让 Codex、Claude、Cursor 等开发 Agent 直接管理钉钉开放平台应用:
创建应用、配置机器人、申请权限、管理安全配置、发布版本。
File diff suppressed because it is too large Load Diff
+15 -7
View File
@@ -13,9 +13,14 @@
打开终端,整段复制执行:
```bash
curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.sh | sh
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
```
> 国内用户:`dws dev` 已在正式版里,直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
> ```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
> ```
装完按提示把 `~/.local/bin` 加进 `PATH`(脚本会在末尾提示),然后执行 `dws version` 确认。
### Windows
@@ -23,12 +28,12 @@ curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/fe
打开 PowerShell,整段复制执行:
```powershell
irm https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.ps1 | iex
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
```
然后**重新打开一个 PowerShell 窗口**,执行 `dws version` 确认。
> 能打印出版本号即安装成功(脚本默认装当前最新的预览版)。脚本走 GitHub API 取最新预览版,无需手动填版本号;想钉某个版本可设环境变量 `DEVAPP_VERSION`。
> 能打印出版本号即安装成功(脚本默认装最新正式版)。脚本走 GitHub API 取最新 release,无需手动填版本号;想钉某个版本可设环境变量 `DEVAPP_VERSION`。
### 登录钉钉
@@ -50,18 +55,21 @@ dws dev app robot submit --name 我的智能体 --robot-name 小助手 --desc "
dws dev app robot result --task-id 上一步返回的taskId --format json
```
`status` 变成 `SUCCESS` 后,返回结果里的 `clientId` 和 `clientSecret` **保存好**,下一步要用。
`status` 变成 `SUCCESS` 后,返回结果里的 `unifiedAppId` **记下来**,下一步要用。(`clientId` / `clientSecret` 也会返回,但下一步默认走 `unifiedAppId`,密钥由 dws 后台从 `credentials get` 自动拉取,你不需要手工复制密钥。)
## 第三步:把机器人接上你本地的 AI
```bash
dws dev connect --channel auto --robot-client-id dingxxxxxxxxxxxxxxxx --robot-client-secret yyyyyyyyyyyyyyyyyyyy
dws dev connect --channel auto --unified-app-id 上一步的unifiedAppId
```
- 把 `dingxxxxxxxxxxxxxxxx` 和 `yyyyyyyyyyyyyyyyyyyy` 换成第二步返回的 `clientId` 和 `clientSecret` 的实际值
- 把 `上一步的unifiedAppId` 换成第二步返回的 `unifiedAppId` 实际值
- 只用 `--unified-app-id`:`clientSecret` 由 `dws dev app credentials get` 后台取回,**不会出现在你的命令行**,不会被 `ps` 看到、不会留在 shell 历史里
- `--channel auto` 自动识别你电脑上装的 AI 工具(Claude Code / Codex / Qoder / Gemini 等)
- 这个命令是前台运行的:窗口开着机器人在线,关掉窗口机器人下线
> 安全提示:老写法 `--robot-client-id <id> --robot-client-secret <secret>` 仍然能用,但 `clientSecret` 会以明文出现在命令行,任何本机用户 `ps -ef` 都能拉到;dws 会在 stderr 打一条 WARNING 提醒。除了没有 unifiedAppId 的老应用兜底之外,都建议改用 `--unified-app-id`。
## 第四步:拉进群聊
在钉钉里打开目标群:
@@ -90,7 +98,7 @@ dws dev connect --channel auto --robot-client-id dingxxxxxxxxxxxxxxxx --robot-cl
```bash
dws dev connect \
--agent-cmd "你的AI命令 一次性问答参数" \
--robot-client-id dingxxxx --robot-client-secret yyyy
--unified-app-id 你的unifiedAppId
```
机器人收到群消息后,会执行 `你的AI命令 一次性问答参数 "用户的问题"`(问题作为最后一个参数追加),把它打印出来的内容当作回复发回群里。
+3 -3
View File
@@ -4,10 +4,13 @@ go 1.25.8
require (
github.com/RealAlexandreAI/json-repair v0.0.15
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/fatih/color v1.18.0
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.18
github.com/muesli/termenv v0.16.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
@@ -21,9 +24,7 @@ require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/bubbletea v1.3.6 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
@@ -44,7 +45,6 @@ require (
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.20.0 // indirect
+277 -48
View File
@@ -39,11 +39,12 @@ import (
)
type authLoginConfig struct {
Token string
Force bool
Device bool
Recommend bool
Yes bool
Token string
Force bool
Device bool
Recommend bool
Yes bool
TargetCorpID string
}
type authLoginGuideAction string
@@ -109,10 +110,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
示例:
dws auth login # 本机登录后选择推荐/全部权限与授权业务域
dws auth login # 本机登录并新增/刷新一个组织 profile
dws auth login --profile <corpId> # 指定本次授权目标组织,不持久切换当前组织
dws auth login --recommend # 无交互批量授权服务端推荐权限
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
dws auth login --force # 强制重新登录 (忽略缓存 token)
dws auth login --force # 兼容保留;login 默认已忽略缓存并进入授权流程
dws auth login --token xxx # 使用指定 token`,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -154,8 +156,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
provider := authpkg.NewOAuthProvider(configDir, nil)
provider.Output = cmd.ErrOrStderr()
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
provider.TargetCorpID = cfg.TargetCorpID
configureOAuthProviderCompatibility(provider, configDir)
tokenData, err = provider.Login(loginCtx, cfg.Force)
tokenData, err = provider.Login(loginCtx, authLoginForcesAuthorization(cfg))
if err != nil {
return apperrors.NewAuth(fmt.Sprintf("dingtalk login failed: %v", err))
}
@@ -163,6 +166,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
ResetRuntimeTokenCache()
clearCompatCache()
if tokenData != nil && strings.TrimSpace(tokenData.CorpID) != "" {
_ = enrichAuthLoginProfileFromContact(cmd.Context(), configDir, patCaller, tokenData)
ResetRuntimeTokenCache()
clearCompatCache()
}
w := cmd.OutOrStdout()
runPostLoginAuthorization := func() error {
@@ -217,7 +225,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
if err := runPostLoginAuthorization(); err != nil {
return err
}
return writeAuthLoginJSON(w, tokenData, cfg.Force)
return writeAuthLoginJSON(w, tokenData, authLoginForcesAuthorization(cfg))
}
// Default table output
@@ -225,7 +233,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
return err
}
fmt.Fprintln(w)
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !cfg.Force {
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !authLoginForcesAuthorization(cfg) {
fmt.Fprintln(w, authLoginStatusLine("Token 有效,无需重新登录"))
} else {
fmt.Fprintln(w, authLoginStatusLine("登录成功!"))
@@ -250,7 +258,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
}
cmd.Flags().String("token", "", "Access token")
cmd.Flags().Bool("device", false, "Use device authorization flow")
cmd.Flags().Bool("force", false, "Force interactive login (ignore cached token)")
cmd.Flags().Bool("force", false, "兼容保留;login 默认已忽略缓存并进入授权流程")
cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限")
// Hidden compatibility flags
cmd.Flags().String("redirect-url", "", "Loopback redirect URL")
@@ -373,58 +381,67 @@ func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) {
}
func newAuthLogoutCommand() *cobra.Command {
return &cobra.Command{
Use: "logout",
Short: "清除认证信息",
cmd := &cobra.Command{
Use: "logout",
Short: "清除认证信息(默认退出所有组织)",
Long: `清除本机钉钉登录态。
默认退出所有已登录组织 profile;指定 --profile 时只退出该组织,不影响其他组织。`,
Example: ` dws auth logout
dws auth logout --profile <corpId>
dws auth logout --profile "钉钉"`,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
configDir := defaultConfigDir()
profileSelector, err := cmd.Flags().GetString("profile")
if err != nil {
return apperrors.NewInternal("failed to read --profile")
}
revokeCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second)
defer cancel()
_ = authpkg.RevokeTokenRemote(revokeCtx)
// Load token data to get associated clientId before deletion
var storedClientID string
if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil {
storedClientID = tokenData.ClientID
if strings.TrimSpace(profileSelector) != "" {
if err := logoutOneProfile(cmd, revokeCtx, configDir, profileSelector); err != nil {
return err
}
} else {
if err := logoutAllProfiles(cmd, revokeCtx, configDir); err != nil {
return err
}
}
if err := authpkg.DeleteTokenData(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
}
// Clean up associated client secret and app token from keychain
if storedClientID != "" {
_ = authpkg.DeleteClientSecret(storedClientID)
_ = authpkg.DeleteAppTokenData(storedClientID)
}
// Also try cleaning app token using appKey from app config
if appKey, _ := authpkg.ResolveAppCredentials(configDir); appKey != "" && appKey != storedClientID {
_ = authpkg.DeleteAppTokenData(appKey)
}
// Clean up app credentials (app.json + keychain secret)
_ = authpkg.DeleteAppConfig(configDir)
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
_ = os.Remove(filepath.Join(configDir, "token"))
_ = os.Remove(filepath.Join(configDir, "token.json"))
ResetRuntimeTokenCache()
clearCompatCache()
w := cmd.OutOrStdout()
fmt.Fprintln(w, "[OK] 已清除所有认证信息")
fmt.Fprintln(w, "[OK] 已清除认证信息")
if !edition.Get().IsEmbedded {
fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录")
}
return nil
},
}
cmd.Flags().String("profile", "", "指定要退出的 profile 名或 corpId")
return cmd
}
func newAuthStatusCommand() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "查看认证状态",
cmd := &cobra.Command{
Use: "status",
Short: "查看认证状态",
Long: `查看当前或指定组织 profile 的认证状态。
指定 --profile 时只读取并刷新被选中的 token slot,不会修改 currentProfile。`,
Example: ` dws auth status
dws auth status --profile <corpId>
dws auth status --profile "钉钉"
dws auth status --profile <corpId> --format json`,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
configDir := defaultConfigDir()
profileSelector, err := cmd.Flags().GetString("profile")
if err != nil {
return apperrors.NewInternal("failed to read --profile")
}
restoreProfile := pushRuntimeProfile(profileSelector)
defer restoreProfile()
authenticated := false
refreshed := false
@@ -444,6 +461,8 @@ func newAuthStatusCommand() *cobra.Command {
}
} else if edition.Get().AutoPurgeToken {
_ = authpkg.DeleteTokenData(configDir)
} else if tokenData != nil {
_ = authpkg.MarkProfileStatus(configDir, tokenData.CorpID, authpkg.ProfileStatusExpired)
}
}
if authStatusAuthenticated(tokenData) {
@@ -467,6 +486,12 @@ func newAuthStatusCommand() *cobra.Command {
fmt.Fprintf(w, "%-16s%s\n", "状态:", "已登录 ✅")
}
if tokenData != nil {
if tokenData.CorpName != "" {
fmt.Fprintf(w, "%-16s%s\n", "企业:", tokenData.CorpName)
}
if tokenData.CorpID != "" {
fmt.Fprintf(w, "%-16s%s\n", "企业 ID:", tokenData.CorpID)
}
if tokenData.IsRefreshTokenValid() {
fmt.Fprintf(w, "%-16s%s\n", "Refresh Token:", "有效 ✅")
} else {
@@ -485,6 +510,74 @@ func newAuthStatusCommand() *cobra.Command {
return nil
},
}
cmd.Flags().String("profile", "", "指定要查看的 profile 名或 corpId")
return cmd
}
func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error {
if _, err := authpkg.ResolveProfile(configDir, selector); err != nil {
return apperrors.NewValidation(err.Error())
}
restoreProfile := pushRuntimeProfile(selector)
defer restoreProfile()
_ = authpkg.RevokeTokenRemote(ctx)
if err := authpkg.DeleteTokenDataForProfile(configDir, selector); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
}
return nil
}
func logoutAllProfiles(_ *cobra.Command, ctx context.Context, configDir string) error {
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
}
if cfg == nil || len(cfg.Profiles) == 0 {
_ = authpkg.RevokeTokenRemote(ctx)
} else {
for _, profile := range cfg.Profiles {
restoreProfile := pushRuntimeProfile(profile.CorpID)
_ = authpkg.RevokeTokenRemote(ctx)
restoreProfile()
}
}
if err := authpkg.DeleteAllTokenData(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
}
return nil
}
func pushRuntimeProfile(selector string) func() {
selector = strings.TrimSpace(selector)
if selector == "" {
return func() {}
}
previous := authpkg.RuntimeProfile()
authpkg.SetRuntimeProfile(selector)
return func() {
authpkg.SetRuntimeProfile(previous)
}
}
func cleanupAuthConfigIfNoProfiles(configDir string) {
cfg, err := authpkg.LoadProfiles(configDir)
if err == nil && len(cfg.Profiles) > 0 {
return
}
if authpkg.TokenDataExistsKeychain() {
return
}
appKey, _ := authpkg.ResolveAppCredentials(configDir)
if appKey != "" {
_ = authpkg.DeleteAppTokenData(appKey)
}
_ = authpkg.DeleteAppConfig(configDir)
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
_ = os.Remove(filepath.Join(configDir, "token"))
_ = authpkg.DeleteTokenMarker(configDir)
}
func newAuthExportCommand() *cobra.Command {
@@ -683,11 +776,12 @@ func newAuthResetCommand() *cobra.Command {
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
configDir := defaultConfigDir()
if err := authpkg.DeleteTokenData(configDir); err != nil {
if err := authpkg.DeleteAllTokenData(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err))
}
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
_ = os.Remove(filepath.Join(configDir, "token"))
_ = authpkg.DeleteAppConfig(configDir)
ResetRuntimeTokenCache()
clearCompatCache()
w := cmd.OutOrStdout()
@@ -958,18 +1052,153 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend")
}
yes := false
profileSelector := ""
if cmd.Root() != nil {
yes, _ = cmd.Root().PersistentFlags().GetBool("yes")
profileSelector, _ = cmd.Root().PersistentFlags().GetString("profile")
}
targetCorpID, err := resolveAuthLoginTargetCorpID(defaultConfigDir(), profileSelector)
if err != nil {
return authLoginConfig{}, err
}
return authLoginConfig{
Token: strings.TrimSpace(token),
Force: force,
Device: device,
Recommend: recommend,
Yes: yes,
Token: strings.TrimSpace(token),
Force: force,
Device: device,
Recommend: recommend,
Yes: yes,
TargetCorpID: targetCorpID,
}, nil
}
func authLoginForcesAuthorization(_ authLoginConfig) bool {
return true
}
func resolveAuthLoginTargetCorpID(configDir, selector string) (string, error) {
selector = strings.TrimSpace(selector)
if selector == "" {
return "", nil
}
if profile, err := authpkg.ResolveProfile(configDir, selector); err == nil && profile != nil {
return strings.TrimSpace(profile.CorpID), nil
}
if strings.HasPrefix(selector, "ding") {
return selector, nil
}
return "", apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector))
}
type contactProfileIdentity struct {
CorpID string
CorpName string
UserID string
UserName string
}
func enrichAuthLoginProfileFromContact(ctx context.Context, configDir string, caller edition.ToolCaller, data *authpkg.TokenData) error {
if caller == nil || data == nil {
return nil
}
corpID := strings.TrimSpace(data.CorpID)
if corpID == "" {
return nil
}
if strings.TrimSpace(data.CorpName) != "" && strings.TrimSpace(data.UserID) != "" && strings.TrimSpace(data.UserName) != "" {
return nil
}
restoreProfile := pushRuntimeProfile(corpID)
defer restoreProfile()
ResetRuntimeTokenCache()
result, err := caller.CallTool(ctx, "contact", "get_current_user_profile", map[string]any{
"profile": corpID,
})
if err != nil {
return err
}
identity, ok := contactProfileIdentityFromToolResult(result)
if !ok {
return nil
}
if identity.CorpID != "" && identity.CorpID != corpID {
return fmt.Errorf("contact profile corpId %q does not match login corpId %q", identity.CorpID, corpID)
}
updated := *data
if identity.CorpName != "" {
updated.CorpName = identity.CorpName
}
if identity.UserID != "" {
updated.UserID = identity.UserID
}
if identity.UserName != "" {
updated.UserName = identity.UserName
}
if updated.CorpName == data.CorpName && updated.UserID == data.UserID && updated.UserName == data.UserName {
return nil
}
if err := authpkg.SaveTokenData(configDir, &updated); err != nil {
return err
}
*data = updated
return nil
}
func contactProfileIdentityFromToolResult(result *edition.ToolResult) (contactProfileIdentity, bool) {
if result == nil {
return contactProfileIdentity{}, false
}
for _, block := range result.Content {
if strings.TrimSpace(block.Text) == "" {
continue
}
if identity, ok := contactProfileIdentityFromJSON([]byte(block.Text)); ok {
return identity, true
}
}
return contactProfileIdentity{}, false
}
func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool) {
var payload struct {
Result []struct {
OrgEmployeeModel struct {
CorpID string `json:"corpId"`
OrgName string `json:"orgName"`
UserID string `json:"userId"`
UserIDLower string `json:"userid"`
OrgUserName string `json:"orgUserName"`
Name string `json:"name"`
} `json:"orgEmployeeModel"`
} `json:"result"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return contactProfileIdentity{}, false
}
if len(payload.Result) == 0 {
return contactProfileIdentity{}, false
}
org := payload.Result[0].OrgEmployeeModel
identity := contactProfileIdentity{
CorpID: strings.TrimSpace(org.CorpID),
CorpName: strings.TrimSpace(org.OrgName),
UserID: firstNonEmptyString(org.UserID, org.UserIDLower),
UserName: firstNonEmptyString(org.OrgUserName, org.Name),
}
return identity, identity.CorpID != "" || identity.CorpName != "" || identity.UserID != "" || identity.UserName != ""
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func authStatusAuthenticated(data *authpkg.TokenData) bool {
if data == nil {
return false
+249
View File
@@ -184,6 +184,161 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
}
}
func TestAuthStatusTableIncludesCorpName(t *testing.T) {
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_primary"))
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "auth", "status"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth status --format table error = %v\noutput:\n%s", err, out.String())
}
for _, want := range []string{"企业:", "corp_primary org", "企业 ID:", "corp_primary"} {
if !bytes.Contains(out.Bytes(), []byte(want)) {
t.Fatalf("auth status table missing %q in output:\n%s", want, out.String())
}
}
}
func TestAuthStatusProfileOverrideDoesNotSwitchCurrentProfile(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "auth", "status", "--profile", "corp_primary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth status --profile error = %v\noutput:\n%s", err, out.String())
}
for _, want := range []string{"corp_primary org", "corp_primary"} {
if !bytes.Contains(out.Bytes(), []byte(want)) {
t.Fatalf("auth status --profile output missing %q:\n%s", want, out.String())
}
}
if bytes.Contains(out.Bytes(), []byte("corp_secondary org")) {
t.Fatalf("auth status --profile should render selected profile, got:\n%s", out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_secondary" {
t.Fatalf("currentProfile = %q, want unchanged corp_secondary", cfg.CurrentProfile)
}
}
func TestAuthLogoutDefaultDeletesAllProfilesAndPreservesAppConfig(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
if err := authpkg.SaveAppConfig(configDir, &authpkg.AppConfig{
ClientID: "client-app",
ClientSecret: authpkg.PlainSecret("secret-app"),
}); err != nil {
t.Fatalf("SaveAppConfig() error = %v", err)
}
originalTransport := http.DefaultTransport
t.Cleanup(func() {
http.DefaultTransport = originalTransport
})
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, errors.New("remote revoke disabled in unit test")
})
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"auth", "logout"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth logout error = %v\noutput:\n%s", err, out.String())
}
for _, want := range []string{"[OK] 已清除认证信息", "重新登录"} {
if !strings.Contains(out.String(), want) {
t.Fatalf("auth logout output missing %q:\n%s", want, out.String())
}
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "" || cfg.PreviousProfile != "" || len(cfg.Profiles) != 0 {
t.Fatalf("profiles after logout = %#v, want empty", cfg)
}
if authpkg.TokenDataExistsKeychainForCorpID("corp_primary") {
t.Fatal("primary profile token should be deleted")
}
if authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") {
t.Fatal("secondary profile token should be deleted")
}
if authpkg.TokenDataExistsKeychain() {
t.Fatal("legacy auth-token mirror should be deleted")
}
appConfig, err := authpkg.LoadAppConfig(configDir)
if err != nil {
t.Fatalf("LoadAppConfig() error = %v", err)
}
if appConfig == nil || appConfig.ClientID != "client-app" {
t.Fatalf("app config after logout = %#v, want preserved client-app", appConfig)
}
}
func TestAuthLogoutProfileDeletesOnlySelectedProfile(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
originalTransport := http.DefaultTransport
t.Cleanup(func() {
http.DefaultTransport = originalTransport
})
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, errors.New("remote revoke disabled in unit test")
})
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"auth", "logout", "--profile", "corp_primary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth logout --profile corp_primary error = %v\noutput:\n%s", err, out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.PrimaryProfile != "corp_secondary" || cfg.CurrentProfile != "corp_secondary" {
t.Fatalf("profiles pointers = primary %q current %q, want corp_secondary/corp_secondary", cfg.PrimaryProfile, cfg.CurrentProfile)
}
if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != "corp_secondary" {
t.Fatalf("profiles = %#v, want only corp_secondary retained", cfg.Profiles)
}
if authpkg.TokenDataExistsKeychainForCorpID("corp_primary") {
t.Fatal("selected primary profile token should be deleted")
}
if !authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") {
t.Fatal("unselected secondary profile token should be retained")
}
loaded, err := authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if loaded.CorpID != "corp_secondary" || loaded.AccessToken != "access-corp_secondary" {
t.Fatalf("default token = (%q, %q), want retained secondary token", loaded.CorpID, loaded.AccessToken)
}
}
func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) {
newRoot := func(t *testing.T) *cobra.Command {
t.Helper()
@@ -297,6 +452,15 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
}
}
func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) {
if !authLoginForcesAuthorization(authLoginConfig{}) {
t.Fatal("auth login should force authorization by default so each login can add an organization profile")
}
if !authLoginForcesAuthorization(authLoginConfig{Force: false}) {
t.Fatal("Force=false should still force authorization")
}
}
func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
@@ -578,6 +742,53 @@ func TestAuthLoginDefaultTUIRunsAfterLoginTokenSaved(t *testing.T) {
}
}
func TestEnrichAuthLoginProfileFromContactPersistsCorpName(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
token := &authpkg.TokenData{
AccessToken: "access-token",
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "ding32fff839a3e0105d",
ClientID: "client-id",
Source: "mcp",
}
if err := authpkg.SaveTokenData(configDir, token); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
fake := &authLoginRecommendSequenceCaller{responses: []string{
`{"success":true,"result":[{"orgEmployeeModel":{"corpId":"ding32fff839a3e0105d","orgName":"钉钉(中国)信息技术有限公司","userId":"011352590165863362195","orgUserName":"玄玦(主用钉)"}}]}`,
}}
if err := enrichAuthLoginProfileFromContact(context.Background(), configDir, fake, token); err != nil {
t.Fatalf("enrichAuthLoginProfileFromContact() error = %v", err)
}
if token.CorpName != "钉钉(中国)信息技术有限公司" {
t.Fatalf("token corpName = %q, want 钉钉(中国)信息技术有限公司", token.CorpName)
}
if token.UserID != "011352590165863362195" || token.UserName != "玄玦(主用钉)" {
t.Fatalf("token user identity = (%q, %q), want contact result", token.UserID, token.UserName)
}
loaded, err := authpkg.LoadTokenDataForProfile(configDir, "ding32fff839a3e0105d")
if err != nil {
t.Fatalf("LoadTokenDataForProfile() error = %v", err)
}
if loaded.CorpName != "钉钉(中国)信息技术有限公司" {
t.Fatalf("persisted corpName = %q, want 钉钉(中国)信息技术有限公司", loaded.CorpName)
}
if len(fake.tools) != 1 || fake.tools[0] != "get_current_user_profile" {
t.Fatalf("tool calls = %v, want get_current_user_profile", fake.tools)
}
if got := fake.args[0]["profile"]; got != "ding32fff839a3e0105d" {
t.Fatalf("contact profile arg = %#v, want ding32fff839a3e0105d", got)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -642,3 +853,41 @@ func stringSliceArgEqual(got any, want []string) bool {
return false
}
}
func setupAuthLogoutProfiles(t *testing.T, tokens ...*authpkg.TokenData) string {
t.Helper()
root := t.TempDir()
configDir := filepath.Join(root, "config")
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain"))
t.Setenv("DWS_CONFIG_DIR", configDir)
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
clearCompatCache()
t.Cleanup(func() {
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
clearCompatCache()
})
for _, token := range tokens {
if err := authpkg.SaveTokenData(configDir, token); err != nil {
t.Fatalf("SaveTokenData(%s) error = %v", token.CorpID, err)
}
}
return configDir
}
func authLogoutTestToken(corpID string) *authpkg.TokenData {
return &authpkg.TokenData{
AccessToken: "access-" + corpID,
RefreshToken: "refresh-" + corpID,
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: corpID,
CorpName: corpID + " org",
UserID: "user-" + corpID,
UserName: "User " + corpID,
ClientID: "client-" + corpID,
}
}
+203
View File
@@ -0,0 +1,203 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"encoding/json"
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
"github.com/spf13/cobra"
)
// toolMappingParam 描述一个 MCP 参数到 CLI flag + 中文友好名的映射。
type toolMappingParam struct {
Flag string `json:"flag"`
Label string `json:"label"`
Type string `json:"type,omitempty"`
}
// toolMappingEntry 是单个 MCP 工具的映射条目。key 用 RPCName,对齐 SLS 日志的 tool 字段。
type toolMappingEntry struct {
Product string `json:"product"`
CLICommand string `json:"cliCommand"`
DisplayName string `json:"displayName"`
Params map[string]toolMappingParam `json:"params,omitempty"`
}
// toolMapping 是给开放平台日志页渲染用的全量映射契约。
type toolMapping struct {
Version string `json:"version"`
Count int `json:"count"`
Tools map[string]toolMappingEntry `json:"tools"`
}
// newCatalogCommand 提供 `dws catalog export`:把已发现的工具目录投影成
// tool→指令 映射 JSON,供开放平台 MCP/DWS 日志页把 tool/args 渲染成中文友好名。
// 复用 root 注入的带 auth 的 loader(缓存优先;建议先 `dws cache refresh`)。
func newCatalogCommand(loader cli.CatalogLoader) *cobra.Command {
catalogCmd := &cobra.Command{
Use: "catalog",
Short: "导出已发现的工具目录(内部用)",
Hidden: true,
}
var out string
var version string
exportCmd := &cobra.Command{
Use: "export",
Short: "导出 tool→指令 映射 JSON(供开放平台日志页渲染)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
catalog, err := loader.Load(cmd.Context())
if err != nil {
return err
}
mapping := projectToolMapping(catalog, version)
data, err := json.MarshalIndent(mapping, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
if strings.TrimSpace(out) == "" {
_, werr := os.Stdout.Write(data)
return werr
}
return os.WriteFile(out, data, 0o644)
},
}
exportCmd.Flags().StringVar(&out, "out", "", "输出文件路径(默认 stdout)")
exportCmd.Flags().StringVar(&version, "version", "dev", "版本号标记")
catalogCmd.AddCommand(exportCmd)
return catalogCmd
}
// projectToolMapping 把 ir.Catalog 投影成 toolMapping 契约。
func projectToolMapping(catalog ir.Catalog, version string) toolMapping {
mapping := toolMapping{Version: version, Tools: make(map[string]toolMappingEntry)}
for _, product := range catalog.Products {
command := ""
if product.CLI != nil {
command = strings.TrimSpace(product.CLI.Command)
}
if command == "" {
command = product.ID
}
for _, tool := range product.Tools {
if tool.Hidden {
continue
}
entry := toolMappingEntry{
Product: command,
CLICommand: tmBuildCLICommand(command, tool),
DisplayName: tmFirstNonEmpty(tool.Title, tmFirstNonEmpty(tmFirstLine(tool.Description), tool.RPCName)),
Params: make(map[string]toolMappingParam),
}
for name, raw := range tmSchemaProperties(tool.InputSchema) {
prop, _ := raw.(map[string]any)
overlay, hasOverlay := tool.FlagOverlay[name]
if hasOverlay && overlay.Hidden {
continue
}
flag := tmKebab(name)
if hasOverlay && strings.TrimSpace(overlay.Alias) != "" {
flag = strings.TrimSpace(overlay.Alias)
}
label := tmMapStr(prop, "title")
if label == "" {
label = tmFirstLine(tmMapStr(prop, "description"))
}
entry.Params[name] = toolMappingParam{
Flag: flag,
Label: label,
Type: tmMapStr(prop, "type"),
}
}
if len(entry.Params) == 0 {
entry.Params = nil
}
mapping.Tools[tool.RPCName] = entry
}
}
mapping.Count = len(mapping.Tools)
return mapping
}
// tmBuildCLICommand 拼出 CLI 命令路径,如 chat + message + list -> "chat message list"。
func tmBuildCLICommand(command string, tool ir.ToolDescriptor) string {
parts := make([]string, 0, 3)
if command != "" {
parts = append(parts, command)
}
if g := strings.TrimSpace(tool.Group); g != "" {
parts = append(parts, g)
}
name := strings.TrimSpace(tool.CLIName)
if name == "" {
name = tool.RPCName
}
parts = append(parts, name)
return strings.Join(parts, " ")
}
func tmSchemaProperties(schema map[string]any) map[string]any {
if schema == nil {
return nil
}
props, _ := schema["properties"].(map[string]any)
return props
}
func tmMapStr(m map[string]any, key string) string {
if m == nil {
return ""
}
s, _ := m[key].(string)
return strings.TrimSpace(s)
}
// tmFirstLine 取第一句中文/换行前的片段,作为长描述的短标签兜底。
func tmFirstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexAny(s, "\n。"); i >= 0 {
return strings.TrimSpace(s[:i])
}
return s
}
func tmFirstNonEmpty(a, b string) string {
if strings.TrimSpace(a) != "" {
return strings.TrimSpace(a)
}
return strings.TrimSpace(b)
}
// tmKebab 把 camelCase 参数名转 kebab-case 作为默认 flag。
func tmKebab(s string) string {
var b strings.Builder
for i, r := range s {
if r >= 'A' && r <= 'Z' {
if i > 0 {
b.WriteByte('-')
}
b.WriteRune(r - 'A' + 'a')
continue
}
b.WriteRune(r)
}
return b.String()
}
+2
View File
@@ -29,6 +29,7 @@ type GlobalFlags struct {
JQ string
Mock bool
Output string
Profile string
Timeout int
Token string
Verbose bool
@@ -46,6 +47,7 @@ func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) {
cmd.PersistentFlags().BoolVar(&flags.Mock, "mock", false, "使用 Mock 数据 (开发调试用)")
cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "", "Write command output to a file")
_ = cmd.PersistentFlags().MarkHidden("output")
cmd.PersistentFlags().StringVar(&flags.Profile, "profile", "", "一次性指定本次命令使用的组织 profile 名或 corpId;多个按 CSV 逗号分隔,如 corpA,corpB")
cmd.PersistentFlags().IntVar(&flags.Timeout, "timeout", 30, "HTTP 请求超时时间 (秒)")
cmd.PersistentFlags().StringVar(&flags.Token, "token", "", "Override the configured API token")
_ = cmd.PersistentFlags().MarkHidden("token")
+79 -2
View File
@@ -55,9 +55,11 @@ func TestRootCommandDoesNotInjectPatchedHelpCommands(t *testing.T) {
t.Cleanup(func() { SetDiscoveryBaseURL("") })
root := NewRootCommand()
// `minutes list all` is intentionally provided as a hardcoded helper
// (see internal/helpers/minutes_commands.go) to align with the wukong
// baseline, so it is expected to resolve and is no longer asserted here.
for _, path := range []string{
"chat message list-topic-replies",
"minutes list all",
} {
if cmd := lookupCommand(root, path); cmd != nil {
t.Fatalf("findCommand(%q) = %q, want nil", path, cmd.CommandPath())
@@ -161,11 +163,16 @@ func TestRootHelpUsesMCPOnlySummary(t *testing.T) {
t.Fatalf("root help missing %q:\n%s", want, got)
}
}
for _, unwanted := range []string{"快速开始:", "更多信息:", "auth 认证管理", "Flags:"} {
for _, unwanted := range []string{"快速开始:", "更多信息:", "auth 认证管理"} {
if strings.Contains(got, unwanted) {
t.Fatalf("root help unexpectedly contains %q:\n%s", unwanted, got)
}
}
for _, want := range []string{"Global Flags:", "--profile"} {
if !strings.Contains(got, want) {
t.Fatalf("root help missing %q:\n%s", want, got)
}
}
}
func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) {
@@ -215,6 +222,60 @@ func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) {
}
}
func TestProfileHelpDocumentsMultiProfileUsage(t *testing.T) {
got := executeHelpForTest(t, "profile", "switch", "--help")
for _, want := range []string{
"切换默认组织 profile",
"需要只影响单次业务命令时,请使用全局 --profile",
"dws profile switch --corpId <corpId>",
"dws --profile <corpId> contact user get-self",
"--corpId string",
"--name string",
} {
if !strings.Contains(got, want) {
t.Fatalf("profile switch help missing %q:\n%s", want, got)
}
}
got = executeHelpForTest(t, "profile", "list", "--help")
for _, want := range []string{
"列出本机已登录的所有组织 profile",
"dws profile list --format json",
} {
if !strings.Contains(got, want) {
t.Fatalf("profile list help missing %q:\n%s", want, got)
}
}
}
func TestAuthHelpDocumentsProfileUsage(t *testing.T) {
got := executeHelpForTest(t, "auth", "login", "--help")
if !strings.Contains(got, "dws auth login --profile <corpId>") {
t.Fatalf("auth login help missing --profile example:\n%s", got)
}
got = executeHelpForTest(t, "auth", "status", "--help")
for _, want := range []string{
"查看当前或指定组织 profile 的认证状态",
"只读取并刷新被选中的 token slot",
"dws auth status --profile <corpId>",
} {
if !strings.Contains(got, want) {
t.Fatalf("auth status help missing %q:\n%s", want, got)
}
}
got = executeHelpForTest(t, "auth", "logout", "--help")
for _, want := range []string{
"默认退出所有已登录组织 profile",
"dws auth logout --profile <corpId>",
} {
if !strings.Contains(got, want) {
t.Fatalf("auth logout help missing %q:\n%s", want, got)
}
}
}
func TestRootCommandRegistersUpgradeCommand(t *testing.T) {
root := NewRootCommand()
if cmd := lookupCommand(root, "upgrade"); cmd == nil {
@@ -222,6 +283,22 @@ func TestRootCommandRegistersUpgradeCommand(t *testing.T) {
}
}
func executeHelpForTest(t *testing.T, args ...string) string {
t.Helper()
t.Setenv(cli.CatalogFixtureEnv, "")
t.Setenv(cli.CacheDirEnv, t.TempDir())
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatalf("Execute(%v) error = %v\noutput:\n%s", args, err, out.String())
}
return out.String()
}
func discoveryServerEntry(command, description string, groups, toolOverrides map[string]any) map[string]any {
cliMeta := map[string]any{
"id": command,
+45 -1
View File
@@ -351,15 +351,59 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
detailStart := time.Now()
detailsByID := loadCachedDetailsFast(store, servers)
existingTools := loadCachedToolNames(store, servers)
RecordTiming(ctx, "tool_metadata", time.Since(detailStart))
buildStart := time.Now()
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID)
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID, existingTools)
RecordTiming(ctx, "build_commands", time.Since(buildStart))
return cmds
}
// loadCachedToolNames reads the live tools/list snapshot from disk cache for
// each server and returns a map from CLI server ID (slug) → set of tool names
// the server actually exposes. This is the existence oracle BuildDynamicCommands
// uses to hide phantom override leaves (commands whose backing MCP tool is not
// deployed) from `--help`.
//
// Source note: this reads the `tools/` partition (populated by `dws cache
// refresh` / discovery, keyed by server.Key), NOT the `detail/` partition used
// by loadCachedDetailsFast — the latter is frequently empty even after a
// refresh, so it is unusable as an existence signal.
//
// Keyed by cli.ID so serverOverride routing (e.g. contact → hrmregister)
// resolves against the target server's tool set. A server with no cached tools
// is simply absent from the map; the build guard treats "absent / empty" as
// "unknown" and keeps the command, so a cold cache never blanks the tree.
func loadCachedToolNames(store *cache.Store, servers []market.ServerDescriptor) map[string]map[string]struct{} {
result := make(map[string]map[string]struct{})
if store == nil {
return result
}
partition := editionPartition()
for _, server := range servers {
slug := strings.TrimSpace(server.CLI.ID)
if slug == "" || strings.TrimSpace(server.Key) == "" {
continue
}
snap, _, err := store.LoadTools(partition, server.Key)
if err != nil || len(snap.Tools) == 0 {
continue
}
names := make(map[string]struct{}, len(snap.Tools))
for _, t := range snap.Tools {
if n := strings.TrimSpace(t.Name); n != "" {
names[n] = struct{}{}
}
}
if len(names) > 0 {
result[slug] = names
}
}
return result
}
// loadCachedDetailsFast reads Detail API tool metadata from disk cache only —
// no network calls. Returns whatever is available (fresh or stale).
func loadCachedDetailsFast(store *cache.Store, servers []market.ServerDescriptor) map[string][]market.DetailTool {
+2 -2
View File
@@ -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/bamboo":
case r.URL.Path == "/cli/discovery/apis/cedar":
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/bamboo":
case r.URL.Path == "/cli/discovery/apis/cedar":
_ = json.NewEncoder(w).Encode(map[string]any{
"metadata": map[string]any{"count": 2, "nextCursor": ""},
"servers": []any{
+148
View File
@@ -0,0 +1,148 @@
package app
import (
"context"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
)
func TestRuntimeRunnerAggregatesCommaSeparatedProfiles(t *testing.T) {
setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_a"),
authLogoutTestToken("corp_b"),
)
authpkg.SetRuntimeProfile("corp_a, corp_b")
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
result, err := runner.Run(context.Background(), executor.Invocation{
Kind: "helper_invocation",
CanonicalProduct: "contact",
Tool: "get_current_user_profile",
Params: map[string]any{"limit": 10},
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if got := authpkg.RuntimeProfile(); got != "corp_a, corp_b" {
t.Fatalf("runtime profile after Run = %q, want restored raw selector", got)
}
content := result.Response["content"].(map[string]any)
if content["multiProfile"] != true {
t.Fatalf("multiProfile = %#v, want true", content["multiProfile"])
}
if content["success"] != true {
t.Fatalf("success = %#v, want true", content["success"])
}
profiles := content["profiles"].([]any)
if len(profiles) != 2 {
t.Fatalf("profiles len = %d, want 2", len(profiles))
}
for i, wantCorpID := range []string{"corp_a", "corp_b"} {
entry := profiles[i].(map[string]any)
if entry["corpId"] != wantCorpID {
t.Fatalf("profiles[%d].corpId = %#v, want %q", i, entry["corpId"], wantCorpID)
}
if entry["ok"] != true {
t.Fatalf("profiles[%d].ok = %#v, want true", i, entry["ok"])
}
resultPayload := entry["result"].(map[string]any)
if resultPayload["runtimeProfile"] != wantCorpID {
t.Fatalf("profiles[%d].result.runtimeProfile = %#v, want %q", i, resultPayload["runtimeProfile"], wantCorpID)
}
}
}
func TestRuntimeRunnerDeduplicatesCommaSeparatedProfilesByCorpID(t *testing.T) {
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
authpkg.SetRuntimeProfile("corp_a, corp_a org,corp_b")
selections, multi, err := resolveMultiProfileSelections(configDir, authpkg.RuntimeProfile())
if err != nil {
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
}
if !multi {
t.Fatal("multi = false, want true")
}
if len(selections) != 2 {
t.Fatalf("selections len = %d, want 2", len(selections))
}
if selections[0].Profile.CorpID != "corp_a" || selections[1].Profile.CorpID != "corp_b" {
t.Fatalf("resolved corp IDs = %q, %q; want corp_a, corp_b", selections[0].Profile.CorpID, selections[1].Profile.CorpID)
}
}
func TestRuntimeRunnerKeepsSingleProfileBehavior(t *testing.T) {
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
authpkg.SetRuntimeProfile("corp_a")
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
result, err := runner.Run(context.Background(), executor.Invocation{
Kind: "helper_invocation",
CanonicalProduct: "contact",
Tool: "get_current_user_profile",
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if _, ok := result.Response["content"].(map[string]any)["multiProfile"]; ok {
t.Fatalf("single profile unexpectedly returned aggregate content: %#v", result.Response)
}
if got := authpkg.RuntimeProfile(); got != "corp_a" {
t.Fatalf("runtime profile after Run = %q, want corp_a", got)
}
}
func TestCommaNamedProfileStillResolvesAsSingleProfile(t *testing.T) {
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_comma"), authLogoutTestToken("corp_other"))
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
for i := range cfg.Profiles {
if cfg.Profiles[i].CorpID == "corp_comma" {
cfg.Profiles[i].Name = "alpha,beta"
}
}
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
selections, multi, err := resolveMultiProfileSelections(configDir, "alpha,beta")
if err != nil {
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
}
if multi {
t.Fatalf("multi = true, want false; selections=%#v", selections)
}
}
func TestCommaSeparatedProfileRejectsEmptySelector(t *testing.T) {
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
_, _, err := resolveMultiProfileSelections(configDir, "corp_a,,corp_b")
if err == nil {
t.Fatal("resolveMultiProfileSelections() error = nil, want validation error")
}
if !strings.Contains(err.Error(), "empty profile selector") {
t.Fatalf("error = %q, want empty profile selector", err.Error())
}
}
type multiProfileFallbackRunner struct{}
func (multiProfileFallbackRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
invocation.Implemented = true
return executor.Result{
Invocation: invocation,
Response: map[string]any{
"content": map[string]any{
"runtimeProfile": authpkg.RuntimeProfile(),
"tool": invocation.Tool,
},
},
}, nil
}
+83
View File
@@ -0,0 +1,83 @@
package app
import (
"bytes"
"os"
"path/filepath"
"testing"
)
// writeMultiSkillSrc creates a fake multi skill source tree with the given
// subdir names, each containing a minimal SKILL.md.
func writeMultiSkillSrc(t *testing.T, names ...string) string {
t.Helper()
src := t.TempDir()
for _, n := range names {
dir := filepath.Join(src, n)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+n+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
return src
}
func contains(ss []string, want string) bool {
for _, s := range ss {
if s == want {
return true
}
}
return false
}
// dws-shared must ship even when --skill narrows the set to a single product.
func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
src := writeMultiSkillSrc(t, "dws-shared", "dingtalk-aitable", "dingtalk-calendar")
all, err := listMultiSkillNames(src)
if err != nil {
t.Fatal(err)
}
if !contains(all, "dws-shared") {
t.Fatalf("listMultiSkillNames did not enumerate dws-shared: %v", all)
}
filtered, err := filterMultiSkillNames(all, []string{"aitable"}, nil)
if err != nil {
t.Fatal(err)
}
if contains(filtered, "dws-shared") {
t.Fatalf("precondition: filter should drop dws-shared for -s aitable: %v", filtered)
}
final := ensureMandatorySharedSkill(filtered, all)
if !contains(final, "dws-shared") {
t.Fatalf("ensureMandatorySharedSkill must re-add dws-shared: %v", final)
}
// Actually install with the filtered+mandatory set and assert dws-shared landed.
dest := t.TempDir()
var out, errOut bytes.Buffer
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
t.Fatalf("install: %v (%s)", err, errOut.String())
}
if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil {
t.Fatalf("dws-shared not installed with -s aitable: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "dingtalk-aitable", "SKILL.md")); err != nil {
t.Fatalf("dingtalk-aitable not installed: %v", err)
}
}
// When the source has no dws-shared (older layout), nothing is forced.
func TestP1SharedNoopWhenAbsent(t *testing.T) {
src := writeMultiSkillSrc(t, "dingtalk-aitable")
all, err := listMultiSkillNames(src)
if err != nil {
t.Fatal(err)
}
final := ensureMandatorySharedSkill([]string{"dingtalk-aitable"}, all)
if contains(final, "dws-shared") {
t.Fatalf("must not invent dws-shared when source lacks it: %v", final)
}
}
+4 -1
View File
@@ -148,8 +148,11 @@ func registerStdioServerFromOverlay(
}
}
// nil existingTools: this overlay is built from the plugin's own live tool
// list (detailsByID is derived from it), so there are no phantom leaves to
// guard against here.
cmds := compat.BuildDynamicCommands(
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
[]market.ServerDescriptor{descriptor}, runner, detailsByID, nil)
slog.Debug("plugin: stdio server registered from overlay",
"plugin", p.Manifest.Name, "server", sc.Key,
+82
View File
@@ -0,0 +1,82 @@
package app
import (
"os"
"reflect"
"testing"
)
func TestNormalizeProfileFlagArgsAcceptsUnquotedCommaContinuation(t *testing.T) {
cases := []struct {
name string
args []string
want []string
}{
{
name: "root profile before command",
args: []string{"--mock", "--profile", "corpA,", "corpB", "contact", "user", "get-self"},
want: []string{"--mock", "--profile", "corpA,corpB", "contact", "user", "get-self"},
},
{
name: "profile after leaf command",
args: []string{"contact", "user", "get-self", "--profile", "corpA,", "corpB", "--format", "json"},
want: []string{"contact", "user", "get-self", "--profile", "corpA,corpB", "--format", "json"},
},
{
name: "equals form",
args: []string{"--profile=corpA,", "corpB", "contact", "user", "get-self"},
want: []string{"--profile=corpA,corpB", "contact", "user", "get-self"},
},
{
name: "three profiles",
args: []string{"--profile", "corpA,", "corpB,", "corpC", "contact", "user", "get-self"},
want: []string{"--profile", "corpA,corpB,corpC", "contact", "user", "get-self"},
},
{
name: "already quoted by shell remains unchanged",
args: []string{"--profile", "corpA, corpB", "contact", "user", "get-self"},
want: []string{"--profile", "corpA, corpB", "contact", "user", "get-self"},
},
{
name: "single profile remains unchanged",
args: []string{"--profile", "corpA", "contact", "user", "get-self"},
want: []string{"--profile", "corpA", "contact", "user", "get-self"},
},
{
name: "trailing comma before next flag remains validation input",
args: []string{"--profile", "corpA,", "--format", "json", "contact", "user", "get-self"},
want: []string{"--profile", "corpA,", "--format", "json", "contact", "user", "get-self"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, _ := normalizeProfileFlagArgs(tc.args)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("normalizeProfileFlagArgs() = %#v, want %#v", got, tc.want)
}
})
}
}
func TestPreparseProfileFlagUsesNormalizedProfileArgs(t *testing.T) {
got := preparseProfileFlag([]string{"--profile", "corpA,", "corpB", "contact", "user", "get-self"})
if got != "corpA,corpB" {
t.Fatalf("preparseProfileFlag() = %q, want corpA,corpB", got)
}
}
func TestNormalizeProcessProfileArgsRestoresOriginalArgv(t *testing.T) {
oldArgs := os.Args
t.Cleanup(func() { os.Args = oldArgs })
os.Args = []string{"dws", "--profile", "corpA,", "corpB", "contact", "user", "get-self"}
restore := normalizeProcessProfileArgs()
if want := []string{"dws", "--profile", "corpA,corpB", "contact", "user", "get-self"}; !reflect.DeepEqual(os.Args, want) {
t.Fatalf("os.Args after normalize = %#v, want %#v", os.Args, want)
}
restore()
if want := []string{"dws", "--profile", "corpA,", "corpB", "contact", "user", "get-self"}; !reflect.DeepEqual(os.Args, want) {
t.Fatalf("os.Args after restore = %#v, want %#v", os.Args, want)
}
}
+747
View File
@@ -0,0 +1,747 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"encoding/json"
"errors"
"fmt"
"io"
"sort"
"strings"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
"github.com/spf13/cobra"
)
func newProfileCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "profile",
Short: "组织 profile 管理",
Long: `管理本机已登录的钉钉组织 profile。
每个 profile 对应一个已授权组织。业务命令可通过全局 --profile 临时指定组织,
profile switch/use 才会持久修改默认组织上下文。`,
Example: ` dws profile list
dws profile switch
dws profile switch <corpId>
dws profile switch -
dws --profile <corpId> contact user get-self`,
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
cmd.AddCommand(newProfileListCommand(), newProfileSwitchCommand(), newProfileUseCommand())
return cmd
}
func newProfileListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "列出已登录组织 profile",
Long: "列出本机已登录的所有组织 profile,包含当前组织、主组织、组织名、corpId、状态和用户信息。",
Example: ` dws profile list
dws profile list --format json`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
configDir := defaultConfigDir()
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
}
format, _ := cmd.Root().PersistentFlags().GetString("format")
if strings.EqualFold(strings.TrimSpace(format), "json") {
return writeProfileListJSON(cmd.OutOrStdout(), cfg)
}
writeProfileListTable(cmd.OutOrStdout(), cfg)
return nil
},
}
}
func newProfileUseCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "use [name|corpId|-]",
Short: "切换当前组织 profile(兼容 profile switch)",
Long: "兼容命令,语义等同于 dws profile switch。可用组织名、profile 名、corpId 或 - 切回上一个组织。",
Example: ` dws profile use <corpId>
dws profile use --name "钉钉"
dws profile use -`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runProfileSwitchCommand(cmd, args)
},
}
addProfileSwitchSelectorFlags(cmd)
return cmd
}
func newProfileSwitchCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "switch [name|corpId|-]",
Short: "切换当前组织 profile",
Long: `切换默认组织 profile,并记录 previousProfile 以支持 dws profile switch - 快速切回。
不带参数时,交互终端会展示组织选择器;非交互环境请显式传入组织名、profile 名或 corpId。
需要只影响单次业务命令时,请使用全局 --profile。`,
Example: ` dws profile switch
dws profile switch <corpId>
dws profile switch --corpId <corpId>
dws profile switch --name "钉钉"
dws profile switch -
dws --profile <corpId> contact user get-self`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runProfileSwitchCommand(cmd, args)
},
}
addProfileSwitchSelectorFlags(cmd)
return cmd
}
func addProfileSwitchSelectorFlags(cmd *cobra.Command) {
cmd.Flags().String("corpId", "", "按 corpId 直接切换组织 profile")
cmd.Flags().String("corp-id", "", "按 corpId 直接切换组织 profile")
cmd.Flags().String("corpid", "", "按 corpId 直接切换组织 profile")
cmd.Flags().String("corp", "", "按 corpId 直接切换组织 profile")
cmd.Flags().String("name", "", "按组织名或 profile 名直接切换组织 profile")
_ = cmd.Flags().MarkHidden("corp-id")
_ = cmd.Flags().MarkHidden("corpid")
_ = cmd.Flags().MarkHidden("corp")
}
var (
profileSwitchSelector = selectProfileSwitchProfile
profileSwitchInteractiveTerminal = isInteractiveTerminal
)
const (
profileSwitchVisibleOptions = 5
profileSwitchCellPadding = 1
profileSwitchOrgWidth = 34
profileSwitchStatusWidth = 10
)
var profileSwitchRenderer = newProfileSwitchRenderer()
func newProfileSwitchRenderer() *lipgloss.Renderer {
renderer := lipgloss.NewRenderer(io.Discard)
renderer.SetColorProfile(termenv.TrueColor)
renderer.SetHasDarkBackground(true)
return renderer
}
func runProfileSwitchCommand(cmd *cobra.Command, args []string) error {
configDir := defaultConfigDir()
selector, err := profileSwitchSelectorFromCommand(cmd, args)
if err != nil {
return err
}
usedTUI := false
if selector == "" {
selector, err = profileSwitchSelector(cmd, configDir)
if err != nil {
return err
}
usedTUI = true
}
return switchProfileAndWrite(cmd, configDir, selector, usedTUI)
}
func profileSwitchSelectorFromCommand(cmd *cobra.Command, args []string) (string, error) {
selectors := make([]string, 0, 2)
if len(args) > 0 {
selectors = append(selectors, strings.TrimSpace(args[0]))
}
for _, name := range []string{"corpId", "corp-id", "corpid", "corp", "name"} {
value, changed := changedStringFlag(cmd, name)
if !changed {
continue
}
if value == "" {
return "", apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", name))
}
selectors = append(selectors, value)
}
if len(selectors) == 0 {
return "", nil
}
selector := selectors[0]
for _, candidate := range selectors[1:] {
if candidate != selector {
return "", apperrors.NewValidation("只能指定一个组织选择器,请使用位置参数或 --corpId/--name 其中一种")
}
}
return selector, nil
}
func changedStringFlag(cmd *cobra.Command, name string) (string, bool) {
if cmd == nil || cmd.Flags() == nil {
return "", false
}
flag := cmd.Flags().Lookup(name)
if flag == nil || !flag.Changed {
return "", false
}
return strings.TrimSpace(flag.Value.String()), true
}
func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedTUI bool) error {
var (
profile *authpkg.Profile
err error
)
if strings.TrimSpace(selector) == "-" {
profile, err = authpkg.UsePreviousProfile(configDir)
} else {
profile, err = authpkg.SetCurrentProfile(configDir, selector)
}
if err != nil {
return apperrors.NewValidation(err.Error())
}
ResetRuntimeTokenCache()
clearCompatCache()
format, _ := cmd.Root().PersistentFlags().GetString("format")
if strings.EqualFold(strings.TrimSpace(format), "json") && !(usedTUI && authLoginAllowsInteractiveDefault(cmd, format)) {
cfg, loadErr := authpkg.LoadProfiles(configDir)
if loadErr != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", loadErr))
}
return writeProfileUseJSON(cmd.OutOrStdout(), profile, cfg)
}
fmt.Fprintln(cmd.OutOrStdout(), profileUseMessage(profile))
return nil
}
func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, error) {
if !profileSwitchInteractiveTerminal() {
return "", apperrors.NewValidation("profile selector required in non-interactive mode; use dws profile switch <name|corpId>")
}
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
}
if cfg == nil || len(cfg.Profiles) == 0 {
return "", apperrors.NewValidation("未找到已登录 profile,请先运行 dws auth login")
}
choice := strings.TrimSpace(cfg.CurrentProfile)
if choice == "" {
choice = strings.TrimSpace(cfg.PrimaryProfile)
}
if choice == "" {
choice = cfg.Profiles[0].CorpID
}
return runProfileSwitchTUI(cmd, cfg, choice)
}
func runProfileSwitchTUI(cmd *cobra.Command, cfg *authpkg.ProfilesConfig, selectedCorpID string) (string, error) {
model := newProfileSwitchTUIModel(cfg, selectedCorpID)
program := tea.NewProgram(
model,
tea.WithAltScreen(),
tea.WithInput(cmd.InOrStdin()),
tea.WithOutput(cmd.ErrOrStderr()),
tea.WithContext(cmd.Context()),
)
finalModel, err := program.Run()
if err != nil {
if errors.Is(err, tea.ErrInterrupted) {
return "", apperrors.NewValidation("组织选择中止: user aborted")
}
return "", apperrors.NewInternal(fmt.Sprintf("failed to run profile selector: %v", err))
}
final, ok := finalModel.(profileSwitchTUIModel)
if !ok || final.aborted || !final.submitted {
return "", apperrors.NewValidation("组织选择中止: user aborted")
}
return final.selectedCorpID(), nil
}
type profileSwitchTUIModel struct {
cfg *authpkg.ProfilesConfig
profiles []authpkg.Profile
selected int
offset int
submitted bool
aborted bool
}
func newProfileSwitchTUIModel(cfg *authpkg.ProfilesConfig, selectedCorpID string) profileSwitchTUIModel {
model := profileSwitchTUIModel{cfg: cfg}
if cfg != nil {
model.profiles = profileSwitchSortedProfiles(cfg.Profiles)
}
model.selected = profileSwitchProfileIndex(model.profiles, selectedCorpID)
if model.selected < 0 {
model.selected = 0
}
model.ensureSelectedVisible()
return model
}
func profileSwitchSortedProfiles(profiles []authpkg.Profile) []authpkg.Profile {
sorted := append([]authpkg.Profile(nil), profiles...)
sort.SliceStable(sorted, func(i, j int) bool {
left, leftOK := profileSwitchSortTime(sorted[i])
right, rightOK := profileSwitchSortTime(sorted[j])
if leftOK && rightOK && !left.Equal(right) {
return left.After(right)
}
if leftOK != rightOK {
return leftOK
}
return false
})
return sorted
}
func profileSwitchSortTime(p authpkg.Profile) (time.Time, bool) {
for _, raw := range []string{p.LastLoginAt, p.UpdatedAt, p.LastUsedAt} {
if t, ok := parseProfileSwitchTime(raw); ok {
return t, true
}
}
return time.Time{}, false
}
func parseProfileSwitchTime(raw string) (time.Time, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, false
}
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}, false
}
return t, true
}
func (m profileSwitchTUIModel) Init() tea.Cmd {
return nil
}
func (m profileSwitchTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc", "q":
m.aborted = true
return m, tea.Quit
case "up", "k":
if m.selected > 0 {
m.selected--
m.ensureSelectedVisible()
}
case "down", "j":
if m.selected < len(m.profiles)-1 {
m.selected++
m.ensureSelectedVisible()
}
case "enter":
m.submitted = true
return m, tea.Quit
}
}
return m, nil
}
func (m profileSwitchTUIModel) View() string {
var b strings.Builder
title := profileSwitchTitleStyle().Render("选择要切换的组织")
hint := profileSwitchMutedStyle().Render("全部已登录 profile,↑↓ 选择,Enter 确认")
b.WriteString(title)
b.WriteString("\n")
b.WriteString(hint)
b.WriteString("\n\n")
b.WriteString(m.tableView())
b.WriteString("\n")
b.WriteString(profileSwitchMutedStyle().Render("↑/k up • ↓/j down • enter submit • esc cancel"))
return b.String()
}
func (m profileSwitchTUIModel) tableView() string {
rows := []string{
profileSwitchBorder("┌", "┬", "┐"),
profileSwitchStyledTableLine("组织名", "本地状态", profileSwitchHeaderStyle()),
profileSwitchBorder("├", "┼", "┤"),
}
for i := 0; i < profileSwitchVisibleOptions; i++ {
idx := m.offset + i
if idx >= 0 && idx < len(m.profiles) {
rows = append(rows, m.profileRow(idx))
continue
}
rows = append(rows, profileSwitchStyledTableLine("", "", profileSwitchNormalRowStyle()))
}
rows = append(rows, profileSwitchBorder("└", "┴", "┘"))
return strings.Join(rows, "\n")
}
func (m profileSwitchTUIModel) profileRow(idx int) string {
profile := m.profiles[idx]
org, status := profileSwitchProfileCells(profile, m.cfg)
style := profileSwitchNormalRowStyle()
if idx == m.selected {
org = "› " + org
style = profileSwitchSelectedRowStyle()
} else {
org = " " + org
}
return profileSwitchStyledTableLine(org, status, style)
}
func (m *profileSwitchTUIModel) ensureSelectedVisible() {
if len(m.profiles) == 0 {
m.selected = 0
m.offset = 0
return
}
if m.selected < 0 {
m.selected = 0
}
if m.selected >= len(m.profiles) {
m.selected = len(m.profiles) - 1
}
if m.selected < m.offset {
m.offset = m.selected
}
if m.selected >= m.offset+profileSwitchVisibleOptions {
m.offset = m.selected - profileSwitchVisibleOptions + 1
}
maxOffset := len(m.profiles) - profileSwitchVisibleOptions
if maxOffset < 0 {
maxOffset = 0
}
if m.offset > maxOffset {
m.offset = maxOffset
}
if m.offset < 0 {
m.offset = 0
}
}
func (m profileSwitchTUIModel) selectedCorpID() string {
if m.selected < 0 || m.selected >= len(m.profiles) {
return ""
}
return strings.TrimSpace(m.profiles[m.selected].CorpID)
}
func profileSwitchProfileIndex(profiles []authpkg.Profile, corpID string) int {
corpID = strings.TrimSpace(corpID)
for i, p := range profiles {
if strings.TrimSpace(p.CorpID) == corpID {
return i
}
}
return -1
}
func profileSwitchOptionLabel(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
org, status := profileSwitchProfileCells(p, cfg)
if status == "" {
return org
}
return strings.Join([]string{org, status}, " | ")
}
func profileSwitchProfileCells(p authpkg.Profile, cfg *authpkg.ProfilesConfig) (string, string) {
return profileOrgName(p), profileSwitchProfileStatus(p, cfg)
}
func profileSwitchProfileStatus(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
if cfg != nil && p.CorpID == cfg.CurrentProfile {
return "当前组织"
}
return ""
}
func profileSwitchBorder(left, sep, right string) string {
segments := []string{
strings.Repeat("─", profileSwitchCellWidth(profileSwitchOrgWidth)),
strings.Repeat("─", profileSwitchCellWidth(profileSwitchStatusWidth)),
}
return profileSwitchBorderStyle().Render(left + strings.Join(segments, sep) + right)
}
func profileSwitchTableLine(org, status string) string {
cells := []string{
profileSwitchTableCell(org, profileSwitchOrgWidth),
profileSwitchTableCell(status, profileSwitchStatusWidth),
}
return "│" + strings.Join(cells, "│") + "│"
}
func profileSwitchStyledTableLine(org, status string, style lipgloss.Style) string {
cells := []string{
style.Render(profileSwitchTableCell(org, profileSwitchOrgWidth)),
style.Render(profileSwitchTableCell(status, profileSwitchStatusWidth)),
}
return profileSwitchTableSeparator() + strings.Join(cells, profileSwitchTableSeparator()) + profileSwitchTableSeparator()
}
func profileSwitchTableSeparator() string {
return profileSwitchBorderStyle().Render("│")
}
func profileSwitchTableCell(value string, width int) string {
clipped := clipProfileDisplayCell(strings.TrimSpace(value), width)
padding := strings.Repeat(" ", profileSwitchCellPadding)
return padding + padProfileDisplayCell(clipped, width) + padding
}
func padProfileDisplayCell(value string, width int) string {
padding := width - lipgloss.Width(value)
if padding < 0 {
padding = 0
}
return value + strings.Repeat(" ", padding)
}
func profileSwitchCellWidth(contentWidth int) int {
return contentWidth + profileSwitchCellPadding*2
}
func profileSwitchSelectedRowStyle() lipgloss.Style {
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#69B1FF")).Bold(true)
}
func profileSwitchNormalRowStyle() lipgloss.Style {
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#FFFFFF"))
}
func profileSwitchHeaderStyle() lipgloss.Style {
return profileSwitchMutedStyle().Bold(true)
}
func profileSwitchBorderStyle() lipgloss.Style {
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#2F3B52"))
}
func profileSwitchTitleStyle() lipgloss.Style {
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#69B1FF")).Bold(true)
}
func profileSwitchMutedStyle() lipgloss.Style {
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#8A96A8"))
}
type profileListResponse struct {
Success bool `json:"success"`
PrimaryProfile string `json:"primaryProfile,omitempty"`
CurrentProfile string `json:"currentProfile,omitempty"`
PreviousProfile string `json:"previousProfile,omitempty"`
Profiles []profileView `json:"profiles"`
}
type profileUseResponse struct {
Success bool `json:"success"`
Profile profileView `json:"profile"`
}
type profileView struct {
CorpID string `json:"corpId"`
CorpName string `json:"corpName"`
UserID string `json:"userId,omitempty"`
UserName string `json:"userName,omitempty"`
ClientID string `json:"clientId,omitempty"`
Status string `json:"status,omitempty"`
AuthorizedDomains []string `json:"authorizedDomains,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
RefreshExpAt string `json:"refreshExpAt,omitempty"`
LastLoginAt string `json:"lastLoginAt,omitempty"`
LastUsedAt string `json:"lastUsedAt,omitempty"`
IsPrimary bool `json:"isPrimary"`
IsCurrent bool `json:"isCurrent"`
}
func writeProfileListJSON(w io.Writer, cfg *authpkg.ProfilesConfig) error {
resp := profileListResponse{
Success: true,
PrimaryProfile: cfg.PrimaryProfile,
CurrentProfile: cfg.CurrentProfile,
PreviousProfile: cfg.PreviousProfile,
Profiles: profileViews(cfg),
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
func writeProfileUseJSON(w io.Writer, profile *authpkg.Profile, cfg *authpkg.ProfilesConfig) error {
resp := profileUseResponse{Success: true}
if profile != nil {
primaryProfile := ""
currentProfile := ""
if cfg != nil {
primaryProfile = cfg.PrimaryProfile
currentProfile = cfg.CurrentProfile
}
resp.Profile = profileViewFromProfile(*profile, primaryProfile, currentProfile)
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
func writeProfileListTable(w io.Writer, cfg *authpkg.ProfilesConfig) {
if cfg == nil || len(cfg.Profiles) == 0 {
fmt.Fprintln(w, "未找到已登录 profile")
return
}
fmt.Fprintf(w, "%-3s %-3s %-28s %-34s %-10s %s\n", "CUR", "PRI", "ORG_NAME", "CORP_ID", "STATUS", "USER")
for _, p := range cfg.Profiles {
current := ""
if p.CorpID == cfg.CurrentProfile {
current = "*"
}
primary := ""
if p.CorpID == cfg.PrimaryProfile {
primary = "*"
}
user := p.UserName
if user == "" {
user = p.UserID
}
status := p.Status
if status == "" {
status = authpkg.ProfileStatusActive
}
fmt.Fprintf(
w,
"%-3s %-3s %-28s %-34s %-10s %s\n",
current,
primary,
clipProfileCell(profileOrgName(p), 28),
clipProfileCell(p.CorpID, 34),
status,
user,
)
}
}
func profileUseMessage(profile *authpkg.Profile) string {
if profile == nil {
return "[OK] 当前 profile 已切换"
}
corpID := strings.TrimSpace(profile.CorpID)
orgName := strings.TrimSpace(profile.CorpName)
if orgName == "" {
orgName = profileOrgName(*profile)
}
return fmt.Sprintf("[OK] 当前组织: %s (%s)", orgName, corpID)
}
func profileOrgName(p authpkg.Profile) string {
if v := strings.TrimSpace(p.CorpName); v != "" {
return v
}
if v := strings.TrimSpace(p.Name); v != "" {
return v
}
return strings.TrimSpace(p.CorpID)
}
func profileViews(cfg *authpkg.ProfilesConfig) []profileView {
if cfg == nil {
return nil
}
views := make([]profileView, 0, len(cfg.Profiles))
for _, p := range cfg.Profiles {
views = append(views, profileViewFromProfile(p, cfg.PrimaryProfile, cfg.CurrentProfile))
}
return views
}
func profileViewFromProfile(p authpkg.Profile, primaryProfile, currentProfile string) profileView {
return profileView{
CorpID: p.CorpID,
CorpName: profileOrgName(p),
UserID: p.UserID,
UserName: p.UserName,
ClientID: p.ClientID,
Status: p.Status,
AuthorizedDomains: p.AuthorizedDomains,
ExpiresAt: p.ExpiresAt,
RefreshExpAt: p.RefreshExpAt,
LastLoginAt: p.LastLoginAt,
LastUsedAt: p.LastUsedAt,
IsPrimary: p.CorpID == primaryProfile,
IsCurrent: p.CorpID == currentProfile,
}
}
func clipProfileCell(value string, limit int) string {
if limit <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= limit {
return value
}
if limit <= 3 {
return string(runes[:limit])
}
return string(runes[:limit-3]) + "..."
}
func clipProfileDisplayCell(value string, limit int) string {
if limit <= 0 {
return ""
}
if lipgloss.Width(value) <= limit {
return value
}
if limit <= 3 {
var b strings.Builder
for _, r := range value {
rw := lipgloss.Width(string(r))
if lipgloss.Width(b.String())+rw > limit {
break
}
b.WriteRune(r)
}
return b.String()
}
target := limit - 3
var b strings.Builder
width := 0
for _, r := range value {
rw := lipgloss.Width(string(r))
if width+rw > target {
break
}
b.WriteRune(r)
width += rw
}
return b.String() + "..."
}
+582
View File
@@ -0,0 +1,582 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
)
func TestWriteProfileUseJSONKeepsPrimaryAndCurrentDistinct(t *testing.T) {
profile := &authpkg.Profile{
Name: "B Org",
CorpID: "corp_b",
CorpName: "B Org",
Status: authpkg.ProfileStatusActive,
}
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "corp_a",
CurrentProfile: "corp_b",
}
var buf bytes.Buffer
if err := writeProfileUseJSON(&buf, profile, cfg); err != nil {
t.Fatalf("writeProfileUseJSON() error = %v", err)
}
var resp profileUseResponse
if err := json.Unmarshal(buf.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if bytes.Contains(buf.Bytes(), []byte(`"name"`)) {
t.Fatalf("profile use JSON should not contain name when corpName is present:\n%s", buf.String())
}
if resp.Profile.CorpName != "B Org" {
t.Fatalf("corpName = %q, want B Org", resp.Profile.CorpName)
}
if !resp.Profile.IsCurrent {
t.Fatalf("isCurrent = false, want true")
}
if resp.Profile.IsPrimary {
t.Fatalf("isPrimary = true, want false")
}
}
func TestProfileListRootCommandJSONIncludesCorpName(t *testing.T) {
setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "json", "profile", "list"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile list --format json error = %v\noutput:\n%s", err, out.String())
}
var resp profileListResponse
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String())
}
if !resp.Success {
t.Fatal("success = false, want true")
}
if resp.PrimaryProfile != "corp_primary" || resp.CurrentProfile != "corp_secondary" || resp.PreviousProfile != "corp_primary" {
t.Fatalf("profile pointers = primary %q current %q previous %q, want corp_primary/corp_secondary/corp_primary", resp.PrimaryProfile, resp.CurrentProfile, resp.PreviousProfile)
}
if len(resp.Profiles) != 2 {
t.Fatalf("profiles len = %d, want 2", len(resp.Profiles))
}
if bytes.Contains(out.Bytes(), []byte(`"name"`)) {
t.Fatalf("profile list JSON should not contain name when corpName is present:\n%s", out.String())
}
for _, p := range resp.Profiles {
if p.CorpName == "" {
t.Fatalf("profile %s missing corpName in JSON response: %#v", p.CorpID, p)
}
}
}
func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "profile", "use", "corp_primary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use corp_primary error = %v\noutput:\n%s", err, out.String())
}
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
t.Fatalf("profile use output should include organization name:\n%s", out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" {
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile)
}
legacyToken, err := authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if legacyToken.CorpID != "corp_primary" {
t.Fatalf("legacy token corp = %q, want corp_primary", legacyToken.CorpID)
}
cmd = NewRootCommand()
out.Reset()
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "profile", "use", "-"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use - error = %v\noutput:\n%s", err, out.String())
}
if !bytes.Contains(out.Bytes(), []byte("组织: corp_secondary org")) {
t.Fatalf("profile use - output should include organization name:\n%s", out.String())
}
cfg, err = authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_secondary" || cfg.PreviousProfile != "corp_primary" {
t.Fatalf("profile pointers = current %q previous %q, want corp_secondary/corp_primary", cfg.CurrentProfile, cfg.PreviousProfile)
}
legacyToken, err = authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if legacyToken.CorpID != "corp_secondary" {
t.Fatalf("legacy token corp = %q, want corp_secondary", legacyToken.CorpID)
}
}
func TestProfileSwitchRootCommandSwitchesPrimaryOrganizationAndLegacyMirror(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "profile", "switch", "corp_primary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile switch corp_primary error = %v\noutput:\n%s", err, out.String())
}
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
t.Fatalf("profile switch output should include organization name:\n%s", out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" {
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile)
}
legacyToken, err := authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if legacyToken.CorpID != "corp_primary" {
t.Fatalf("legacy token corp = %q, want corp_primary", legacyToken.CorpID)
}
}
func TestProfileSwitchRootCommandSupportsCorpIDFlag(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "profile", "switch", "--corpId", "corp_primary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile switch --corpId error = %v\noutput:\n%s", err, out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" {
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
}
cmd = NewRootCommand()
out.Reset()
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "profile", "use", "--corp", "corp_secondary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use --corp error = %v\noutput:\n%s", err, out.String())
}
cfg, err = authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_secondary" {
t.Fatalf("currentProfile = %q, want corp_secondary", cfg.CurrentProfile)
}
}
func TestProfileSwitchRootCommandRejectsConflictingSelectors(t *testing.T) {
setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"profile", "switch", "corp_primary", "--corpId", "corp_secondary"})
err := cmd.Execute()
if err == nil {
t.Fatalf("profile switch with conflicting selectors succeeded\noutput:\n%s", out.String())
}
if !strings.Contains(err.Error(), "只能指定一个组织选择器") {
t.Fatalf("error = %v, want conflicting selector validation", err)
}
}
func TestProfileSwitchNoArgsUsesTUISelector(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
oldSelector := profileSwitchSelector
t.Cleanup(func() {
profileSwitchSelector = oldSelector
})
called := false
profileSwitchSelector = func(cmd *cobra.Command, gotConfigDir string) (string, error) {
called = true
if gotConfigDir != configDir {
t.Fatalf("configDir = %q, want %q", gotConfigDir, configDir)
}
return "corp_primary", nil
}
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"profile", "switch"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile switch error = %v\noutput:\n%s", err, out.String())
}
if !called {
t.Fatal("profile switch without args did not invoke TUI selector")
}
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
t.Fatalf("profile switch TUI path should use human output by default:\n%s", out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" {
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
}
}
func TestProfileSwitchOptionLabelUsesOnlyOrganizationAndCurrentState(t *testing.T) {
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "corp_primary",
CurrentProfile: "corp_secondary",
Profiles: []authpkg.Profile{
{
CorpID: "corp_primary",
CorpName: "第一组织",
UserName: "alice",
Status: authpkg.ProfileStatusActive,
},
{
CorpID: "corp_secondary",
CorpName: "第二组织",
UserName: "bob",
Status: authpkg.ProfileStatusActive,
},
},
}
primary := profileSwitchOptionLabel(cfg.Profiles[0], cfg)
current := profileSwitchOptionLabel(cfg.Profiles[1], cfg)
for _, label := range []string{primary, current} {
if strings.Contains(label, "\n") {
t.Fatalf("profile switch label contains newline: %q", label)
}
}
if !strings.Contains(primary, "第一组织") {
t.Fatalf("primary option missing organization name: %q", primary)
}
if !strings.Contains(current, "当前组织") {
t.Fatalf("current option missing current marker: %q", current)
}
for _, unwanted := range []string{"alice", "bob", "已登录", "主组织", "corp_primary", "corp_secondary"} {
if strings.Contains(primary, unwanted) || strings.Contains(current, unwanted) {
t.Fatalf("profile switch option should not contain %q: %q / %q", unwanted, primary, current)
}
}
}
func TestProfileSwitchTUIViewUsesFixedOuterTable(t *testing.T) {
cfg := profileSwitchTestConfig(2)
model := newProfileSwitchTUIModel(cfg, "corp_00")
view := model.tableView()
if lines := strings.Split(view, "\n"); len(lines) != profileSwitchVisibleOptions+4 {
t.Fatalf("table line count = %d, want %d:\n%s", len(lines), profileSwitchVisibleOptions+4, view)
}
for _, want := range []string{"┌", "┬", "┐", "├", "┼", "┤", "└", "┴", "┘", "组织名", "本地状态"} {
if !strings.Contains(view, want) {
t.Fatalf("profile switch table missing %q in:\n%s", want, view)
}
}
for _, unwanted := range []string{"CORP_ID", "ORGANIZATION", "STATUS"} {
if strings.Contains(view, unwanted) {
t.Fatalf("profile switch table should not contain %q:\n%s", unwanted, view)
}
}
if got := strings.Count(view, "│"); got != (profileSwitchVisibleOptions+1)*3 {
t.Fatalf("table vertical separators = %d, want %d\n%s", got, (profileSwitchVisibleOptions+1)*3, view)
}
for _, profile := range cfg.Profiles {
if got := strings.Count(view, profile.CorpID); got != 0 {
t.Fatalf("profile corpId %s appears %d times, want hidden:\n%s", profile.CorpID, got, view)
}
}
}
func TestProfileSwitchTUISortsLatestLoggedInProfilesFirst(t *testing.T) {
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "old",
CurrentProfile: "old",
Profiles: []authpkg.Profile{
{CorpID: "old", CorpName: "旧组织", LastLoginAt: "2026-06-26T10:00:00+08:00"},
{CorpID: "new", CorpName: "新组织", LastLoginAt: "2026-06-26T12:00:00+08:00"},
{CorpID: "fallback", CorpName: "兜底组织", UpdatedAt: "2026-06-26T11:00:00+08:00"},
},
}
model := newProfileSwitchTUIModel(cfg, "old")
gotOrder := []string{model.profiles[0].CorpID, model.profiles[1].CorpID, model.profiles[2].CorpID}
wantOrder := []string{"new", "fallback", "old"}
if strings.Join(gotOrder, ",") != strings.Join(wantOrder, ",") {
t.Fatalf("profile order = %v, want %v", gotOrder, wantOrder)
}
if got := model.selectedCorpID(); got != "old" {
t.Fatalf("selectedCorpID = %q, want old", got)
}
}
func TestProfileSwitchTUIArrowKeysMoveSelectionWithoutDuplicatingRows(t *testing.T) {
cfg := profileSwitchTestConfig(7)
model := newProfileSwitchTUIModel(cfg, "corp_00")
for step := 0; step < 6; step++ {
view := model.tableView()
if got := strings.Count(view, "›"); got != 1 {
t.Fatalf("step %d selected cursor count = %d, want 1:\n%s", step, got, view)
}
for _, profile := range cfg.Profiles {
name := profileOrgName(profile)
if got := strings.Count(view, name); got > 1 {
t.Fatalf("step %d profile %s appears %d times, want at most once:\n%s", step, name, got, view)
}
}
next, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown})
model = next.(profileSwitchTUIModel)
}
if model.selected != 6 || model.offset != 2 {
t.Fatalf("selection after down keys = selected %d offset %d, want 6/2", model.selected, model.offset)
}
}
func TestProfileSwitchTableRowsKeepFixedDisplayWidth(t *testing.T) {
rows := []string{
profileSwitchTableLine("组织名", "本地状态"),
profileSwitchTableLine("› 钉钉(中国)信息技术有限公司", "当前组织"),
profileSwitchTableLine(" ACME", ""),
profileSwitchTableLine("", ""),
profileSwitchStyledTableLine("组织名", "本地状态", profileSwitchHeaderStyle()),
profileSwitchStyledTableLine("› 钉钉(中国)信息技术有限公司", "当前组织", profileSwitchSelectedRowStyle()),
profileSwitchStyledTableLine(" ACME", "", profileSwitchNormalRowStyle()),
profileSwitchStyledTableLine("", "", profileSwitchNormalRowStyle()),
}
wantWidth := lipgloss.Width(rows[0])
for i, row := range rows {
if got := lipgloss.Width(row); got != wantWidth {
t.Fatalf("row[%d] width = %d, want %d: %q", i, got, wantWidth, row)
}
if got := strings.Count(row, "│"); got != 3 {
t.Fatalf("row[%d] separator count = %d, want 3: %q", i, got, row)
}
}
}
func TestProfileSwitchOptionLabelHidesCorpID(t *testing.T) {
const corpID = "ding8196cd9a2b2405da24f2f5cc6abecb85"
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: corpID,
CurrentProfile: corpID,
}
label := profileSwitchOptionLabel(authpkg.Profile{
CorpID: corpID,
CorpName: "钉钉",
}, cfg)
for _, want := range []string{"钉钉", "当前组织"} {
if !strings.Contains(label, want) {
t.Fatalf("profile switch label missing %q in %q", want, label)
}
}
for _, unwanted := range []string{"ding8196", "cb85", "主组织"} {
if strings.Contains(label, unwanted) {
t.Fatalf("profile switch label should not contain %q in %q", unwanted, label)
}
}
}
func profileSwitchTestConfig(count int) *authpkg.ProfilesConfig {
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "corp_00",
CurrentProfile: "corp_00",
}
for i := 0; i < count; i++ {
corpID := fmt.Sprintf("corp_%02d", i)
cfg.Profiles = append(cfg.Profiles, authpkg.Profile{
CorpID: corpID,
CorpName: fmt.Sprintf("组织%02d", i),
Status: authpkg.ProfileStatusActive,
})
}
return cfg
}
func TestAuthCommandDoesNotExposeSwitch(t *testing.T) {
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"auth", "switch"})
err := cmd.Execute()
if err == nil {
t.Fatalf("auth switch succeeded, want unknown command error\noutput:\n%s", out.String())
}
if !strings.Contains(err.Error(), `unknown command "switch" for "dws auth"`) {
t.Fatalf("error = %v, want auth switch unknown command", err)
}
}
func TestProfileUseNoArgsUsesTUISelector(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
oldSelector := profileSwitchSelector
t.Cleanup(func() {
profileSwitchSelector = oldSelector
})
profileSwitchSelector = func(cmd *cobra.Command, gotConfigDir string) (string, error) {
if gotConfigDir != configDir {
t.Fatalf("configDir = %q, want %q", gotConfigDir, configDir)
}
return "corp_primary", nil
}
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"profile", "use"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use error = %v\noutput:\n%s", err, out.String())
}
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
t.Fatalf("profile use TUI path should use human output by default:\n%s", out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" {
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
}
}
func TestProfileSwitchSelectorRequiresInteractiveTerminal(t *testing.T) {
oldInteractive := profileSwitchInteractiveTerminal
t.Cleanup(func() {
profileSwitchInteractiveTerminal = oldInteractive
})
profileSwitchInteractiveTerminal = func() bool { return false }
_, err := selectProfileSwitchProfile(nil, t.TempDir())
if err == nil {
t.Fatal("selectProfileSwitchProfile() succeeded, want validation error")
}
if !bytes.Contains([]byte(err.Error()), []byte("profile selector required")) {
t.Fatalf("error = %v, want profile selector hint", err)
}
}
func TestWriteProfileListTableIncludesCorpName(t *testing.T) {
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "corp_a",
CurrentProfile: "corp_b",
Profiles: []authpkg.Profile{
{
Name: "DingTalk China",
CorpID: "corp_a",
CorpName: "钉钉(中国)信息技术有限公司",
UserName: "alice",
Status: authpkg.ProfileStatusActive,
},
{
Name: "B Org",
CorpID: "corp_b",
CorpName: "B 组织",
UserID: "bob-id",
},
},
}
var buf bytes.Buffer
writeProfileListTable(&buf, cfg)
out := buf.String()
for _, want := range []string{
"ORG_NAME",
"钉钉(中国)信息技术有限公司",
"B 组织",
"corp_a",
"corp_b",
} {
if !bytes.Contains(buf.Bytes(), []byte(want)) {
t.Fatalf("profile list table missing %q in output:\n%s", want, out)
}
}
for _, unwanted := range []string{"PROFILE", "DingTalk China"} {
if bytes.Contains(buf.Bytes(), []byte(unwanted)) {
t.Fatalf("profile list table should not contain %q in output:\n%s", unwanted, out)
}
}
}
func TestProfileUseMessageIncludesCorpName(t *testing.T) {
got := profileUseMessage(&authpkg.Profile{
Name: "DingTalk China",
CorpID: "ding8196",
CorpName: "钉钉(中国)信息技术有限公司",
})
for _, want := range []string{"当前组织: 钉钉(中国)信息技术有限公司", "ding8196"} {
if !bytes.Contains([]byte(got), []byte(want)) {
t.Fatalf("profileUseMessage() missing %q in %q", want, got)
}
}
if bytes.Contains([]byte(got), []byte("DingTalk China")) {
t.Fatalf("profileUseMessage() should not include profile name when corpName is present: %q", got)
}
}
@@ -0,0 +1,158 @@
package app
import (
"bytes"
"context"
"sync"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/spf13/cobra"
)
func TestProductCommandsAcceptGlobalProfileFlag(t *testing.T) {
const selectedProfile = "corp_profile_matrix"
products := []struct {
name string
path []string
tool string
}{
{name: "aitable", path: []string{"aitable", "profile-test", "probe"}, tool: "aitable_profile_probe"},
{name: "attendance", path: []string{"attendance", "profile-test", "probe"}, tool: "attendance_profile_probe"},
{name: "calendar", path: []string{"calendar", "profile-test", "probe"}, tool: "calendar_profile_probe"},
{name: "contact", path: []string{"contact", "profile-test", "probe"}, tool: "contact_profile_probe"},
{name: "devdoc", path: []string{"devdoc", "profile-test", "probe"}, tool: "devdoc_profile_probe"},
{name: "ding", path: []string{"ding", "profile-test", "probe"}, tool: "ding_profile_probe"},
{name: "report", path: []string{"report", "profile-test", "probe"}, tool: "report_profile_probe"},
{name: "todo", path: []string{"todo", "profile-test", "probe"}, tool: "todo_profile_probe"},
}
descriptors := make([]market.ServerDescriptor, 0, len(products))
for _, product := range products {
descriptors = append(descriptors, profileFlagProductDescriptor(product.name, product.tool))
}
capture := &profileFlagRunner{}
oldLoadDynamicCommands := loadDynamicCommandsFn
loadDynamicCommandsFn = func(_ context.Context, _ executor.Runner) []*cobra.Command {
SetDynamicServers(descriptors)
return compat.BuildDynamicCommands(descriptors, capture, nil, nil)
}
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
t.Cleanup(func() {
loadDynamicCommandsFn = oldLoadDynamicCommands
SetDynamicServers(nil)
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
})
for _, product := range products {
t.Run(product.name, func(t *testing.T) {
capture.reset()
authpkg.SetRuntimeProfile("")
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
args := append([]string{"-f", "json"}, product.path...)
args = append(args, "--profile", selectedProfile)
cmd.SetArgs(args)
// Arrange / Act: execute a product command with root --profile after the leaf.
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute(%v) error = %v\noutput:\n%s", args, err, out.String())
}
// Assert: the product tool runs under the selected profile without leaking it as a business arg.
call := capture.last()
if call == nil {
t.Fatal("expected product command to invoke runner")
}
if call.product != product.name {
t.Fatalf("canonical product = %q, want %q", call.product, product.name)
}
if call.tool != product.tool {
t.Fatalf("tool = %q, want %q", call.tool, product.tool)
}
if call.profile != selectedProfile {
t.Fatalf("runtime profile at execution = %q, want %q", call.profile, selectedProfile)
}
if _, ok := call.params["profile"]; ok {
t.Fatalf("--profile leaked into business params: %#v", call.params)
}
})
}
}
func profileFlagProductDescriptor(product, tool string) market.ServerDescriptor {
return market.ServerDescriptor{
Key: product,
DisplayName: product,
Endpoint: "https://example.invalid/" + product,
CLI: market.CLIOverlay{
ID: product,
Command: product,
Groups: map[string]market.CLIGroupDef{
"profile-test": {Description: "profile-test"},
},
ToolOverrides: map[string]market.CLIToolOverride{
tool: {
CLIName: "probe",
Group: "profile-test",
Description: tool,
RejectPositional: true,
},
},
},
}
}
type profileFlagCall struct {
product string
tool string
profile string
params map[string]any
}
type profileFlagRunner struct {
mu sync.Mutex
calls []profileFlagCall
}
func (r *profileFlagRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.mu.Lock()
defer r.mu.Unlock()
params := make(map[string]any, len(invocation.Params))
for key, value := range invocation.Params {
params[key] = value
}
r.calls = append(r.calls, profileFlagCall{
product: invocation.CanonicalProduct,
tool: invocation.Tool,
profile: authpkg.RuntimeProfile(),
params: params,
})
return executor.Result{Invocation: invocation}, nil
}
func (r *profileFlagRunner) reset() {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = nil
}
func (r *profileFlagRunner) last() *profileFlagCall {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.calls) == 0 {
return nil
}
call := r.calls[len(r.calls)-1]
return &call
}
+124 -3
View File
@@ -67,6 +67,9 @@ func Execute() (exitCode int) {
}
}()
restoreArgs := normalizeProcessProfileArgs()
defer restoreArgs()
timing := NewTimingCollector()
defer func() {
StopAllStdioClients() // Ensure child processes are terminated on exit
@@ -96,6 +99,7 @@ func Execute() (exitCode int) {
if executed == nil {
executed = root
}
err = rewordRequiredFlagError(err)
if isUnknownCommandError(err) {
executed.SetOut(os.Stderr)
_ = executed.Help()
@@ -114,6 +118,36 @@ func isUnknownCommandError(err error) bool {
return err != nil && strings.Contains(err.Error(), "unknown command")
}
// rewordRequiredFlagError rewrites cobra's default missing-required-flag message
// (`required flag(s) "email" not set`) into the wukong-aligned form
// (`missing required flag(s): --email`). cobra's ValidateRequiredFlags returns
// this error directly (it does not pass through FlagErrorFunc), so it is
// normalised here. The substring "required flag" is preserved for compatibility
// with existing assertions; flag names gain the "--" prefix and quotes are
// dropped so error output matches hardcoded cmdutil.ValidateRequiredFlags.
func rewordRequiredFlagError(err error) error {
if err == nil {
return err
}
const pfx = "required flag(s) "
const sfx = " not set"
msg := err.Error()
if !strings.HasPrefix(msg, pfx) || !strings.HasSuffix(msg, sfx) {
return err
}
mid := strings.TrimSuffix(strings.TrimPrefix(msg, pfx), sfx)
var flags []string
for _, part := range strings.Split(mid, ", ") {
if name := strings.Trim(strings.TrimSpace(part), "\""); name != "" {
flags = append(flags, "--"+name)
}
}
if len(flags) == 0 {
return err
}
return apperrors.NewValidation(fmt.Sprintf("missing required flag(s): %s", strings.Join(flags, ", ")))
}
// flagErrorWithSuggestions provides helpful suggestions for common flag mistakes.
//
// 所有 flag 解析错误都会在 message 末尾追加 "See '<CommandPath> --help' for usage.",
@@ -267,6 +301,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
rootCtx = context.Background()
}
flags := &GlobalFlags{}
authpkg.SetRuntimeProfile(preparseProfileFlag(os.Args[1:]))
loader := cli.EnvironmentLoader{
LookupEnv: os.LookupEnv,
CatalogBaseURLOverride: DiscoveryBaseURL(),
@@ -290,6 +325,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
return cmd.Help()
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
authpkg.SetRuntimeProfile(flags.Profile)
// Apply OAuth credential overrides from CLI flags (highest priority).
if flags.ClientID != "" {
authpkg.SetClientID(flags.ClientID)
@@ -327,9 +363,11 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
utilityCommands := []*cobra.Command{
newAuthCommand(patCaller),
newProfileCommand(),
newAPICommand(flags),
newSkillCommand(),
newCacheCommand(),
newCatalogCommand(loader),
newConfigCommand(),
newDoctorCommand(),
newCompletionCommand(root),
@@ -372,6 +410,85 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
return root
}
func preparseProfileFlag(args []string) string {
args, _ = normalizeProfileFlagArgs(args)
for i := 0; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
switch {
case arg == "--profile" && i+1 < len(args):
return strings.TrimSpace(args[i+1])
case strings.HasPrefix(arg, "--profile="):
return strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
}
}
return ""
}
func normalizeProcessProfileArgs() func() {
original := append([]string(nil), os.Args...)
if len(os.Args) > 1 {
if normalized, changed := normalizeProfileFlagArgs(os.Args[1:]); changed {
os.Args = append([]string{os.Args[0]}, normalized...)
}
}
return func() {
os.Args = original
}
}
func normalizeProfileFlagArgs(args []string) ([]string, bool) {
if len(args) == 0 {
return args, false
}
out := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
arg := args[i]
trimmed := strings.TrimSpace(arg)
switch {
case trimmed == "--profile":
out = append(out, arg)
if i+1 >= len(args) {
continue
}
value, next := collectProfileFlagValue(args[i+1], args, i+2)
out = append(out, value)
i = next - 1
case strings.HasPrefix(trimmed, "--profile="):
value, next := collectProfileFlagValue(strings.TrimPrefix(trimmed, "--profile="), args, i+1)
out = append(out, "--profile="+value)
i = next - 1
default:
out = append(out, arg)
}
}
return out, argsChanged(args, out)
}
func collectProfileFlagValue(first string, args []string, next int) (string, int) {
parts := []string{strings.TrimSpace(first)}
for len(parts) > 0 && strings.HasSuffix(strings.TrimSpace(parts[len(parts)-1]), ",") && next < len(args) {
candidate := strings.TrimSpace(args[next])
if candidate == "" || strings.HasPrefix(candidate, "-") {
break
}
parts = append(parts, candidate)
next++
}
return strings.Join(parts, ""), next
}
func argsChanged(before, after []string) bool {
if len(before) != len(after) {
return true
}
for i := range before {
if before[i] != after[i] {
return true
}
}
return false
}
func newAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
return buildAuthCommand(patCaller)
}
@@ -770,6 +887,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
"completion": true,
"skill": true,
"plugin": true,
"profile": true,
"version": true,
"help": true,
"recovery": true,
@@ -796,7 +914,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
// by a malicious or misconfigured plugin.
var reservedCommands = map[string]bool{
"auth": true, "api": true, "login": true, "logout": true,
"plugin": true, "skill": true, "cache": true,
"plugin": true, "profile": true, "skill": true, "cache": true,
"config": true, "doctor": true, "completion": true,
"recovery": true, "upgrade": true, "version": true,
"schema": true, "mcp": true, "help": true,
@@ -1471,8 +1589,10 @@ func buildHTTPCommandsFromTools(srv market.ServerDescriptor, tools []transport.T
}
}
// nil existingTools: single-server overlay built from a live tool list, so
// no phantom-leaf guard is needed (see BuildDynamicCommands doc).
return compat.BuildDynamicCommands(
[]market.ServerDescriptor{srv}, runner, detailsByID)
[]market.ServerDescriptor{srv}, runner, detailsByID, nil)
}
// deriveToolCLIName converts an MCP tool name (e.g. "web_search" or
@@ -1659,8 +1779,9 @@ func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []t
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
detailsByID := toolsToDetails(tools, overlay.ID)
// nil existingTools: overlay built from this plugin's live tool list.
cmds := compat.BuildDynamicCommands(
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
[]market.ServerDescriptor{descriptor}, runner, detailsByID, nil)
slog.Debug("plugin: stdio server registered",
"plugin", p.Manifest.Name, "server", sc.Key,
+2 -2
View File
@@ -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/bamboo":
case "/cli/discovery/apis/cedar":
_ = 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/bamboo":
case "/cli/discovery/apis/cedar":
marketHits.Add(1)
http.Error(w, "market endpoint must not be called when edition DiscoveryURL is set", http.StatusNotFound)
default:
+1 -1
View File
@@ -263,7 +263,7 @@ func TestRootHelpDoesNotRequirePINOrLogin(t *testing.T) {
if !strings.Contains(out.String(), "Discovered MCP Services:") {
t.Fatalf("root help output missing MCP summary:\n%s", out.String())
}
for _, want := range []string{"Utility Commands:", "skill", "auth", "version"} {
for _, want := range []string{"Utility Commands:", "skill", "auth", "profile", "version", "Global Flags:", "--profile"} {
if !strings.Contains(out.String(), want) {
t.Fatalf("root help output missing %q:\n%s", want, out.String())
}
+49
View File
@@ -9,6 +9,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func configureRootHelp(root *cobra.Command) {
@@ -86,6 +87,7 @@ func renderRootHelp(root *cobra.Command) {
_ = tw.Flush()
_, _ = fmt.Fprintln(w)
}
renderRootGlobalFlags(root)
_, _ = fmt.Fprintf(w, "%s %s\n", tui.Key("Next"), `Use "dws <service> --help" for more information about a discovered MCP service or "dws <command> --help" for utility commands.`)
// Render root.Long after the command list so agents see the upgrade
@@ -99,6 +101,53 @@ func renderRootHelp(root *cobra.Command) {
}
}
func renderRootGlobalFlags(root *cobra.Command) {
if root == nil {
return
}
flags := visiblePersistentFlags(root)
if len(flags) == 0 {
return
}
w := root.OutOrStdout()
_, _ = fmt.Fprintln(w, tui.Section("Global Flags:"))
_, _ = fmt.Fprintln(w)
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
for _, flag := range flags {
_, _ = fmt.Fprintf(tw, " %s\t%s\n", formatRootFlag(flag), tui.Dim(strings.TrimSpace(flag.Usage)))
}
_ = tw.Flush()
_, _ = fmt.Fprintln(w)
}
func visiblePersistentFlags(root *cobra.Command) []*pflag.Flag {
if root == nil {
return nil
}
flags := make([]*pflag.Flag, 0)
root.PersistentFlags().VisitAll(func(flag *pflag.Flag) {
if flag == nil || flag.Hidden {
return
}
flags = append(flags, flag)
})
return flags
}
func formatRootFlag(flag *pflag.Flag) string {
if flag == nil {
return ""
}
name := "--" + flag.Name
if flag.Value != nil && flag.Value.Type() != "bool" {
name += " " + flag.Value.Type()
}
if flag.Shorthand == "" {
return " " + name
}
return "-" + flag.Shorthand + ", " + name
}
func commandShort(cmd *cobra.Command) string {
if cmd == nil {
return ""
+214 -27
View File
@@ -17,6 +17,7 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -161,6 +162,18 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
// invocations within the same process free.
logHostOwnedPATDecisionOnce()
selections, multi, err := resolveMultiProfileSelections(defaultConfigDir(), authpkg.RuntimeProfile())
if err != nil {
return executor.Result{}, apperrors.NewValidation(err.Error())
}
if multi {
return r.runMultiProfile(ctx, invocation, selections)
}
return r.runSingle(ctx, invocation, true)
}
func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invocation, prefetchToken bool) (executor.Result, error) {
if r.loader == nil || r.transport == nil {
return r.fallback.Run(ctx, invocation)
}
@@ -178,7 +191,9 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
// Prefetch the Keychain token in the background. Keychain access costs
// ~70ms on macOS; starting it here lets the load overlap with endpoint
// resolution and catalog loading below.
go getCachedRuntimeToken(ctx)
if prefetchToken {
go getCachedRuntimeToken(ctx)
}
if shouldUseDirectRuntime(invocation) {
if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok {
@@ -238,6 +253,144 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
return r.executeInvocation(ctx, endpoint, invocation)
}
type multiProfileSelection struct {
Selector string
Profile authpkg.Profile
}
func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfileSelection, bool, error) {
rawSelector = strings.TrimSpace(rawSelector)
if rawSelector == "" || !strings.Contains(rawSelector, ",") {
return nil, false, nil
}
if p, err := authpkg.ResolveProfile(configDir, rawSelector); err == nil && p != nil {
return nil, false, nil
}
parts := strings.Split(rawSelector, ",")
selections := make([]multiProfileSelection, 0, len(parts))
seen := make(map[string]bool, len(parts))
for _, part := range parts {
selector := strings.TrimSpace(part)
if selector == "" {
return nil, false, fmt.Errorf("--profile contains an empty profile selector: %q", rawSelector)
}
profile, err := authpkg.ResolveProfile(configDir, selector)
if err != nil {
return nil, false, err
}
if profile == nil {
return nil, false, fmt.Errorf("profile %q not found", selector)
}
if seen[profile.CorpID] {
continue
}
seen[profile.CorpID] = true
selections = append(selections, multiProfileSelection{
Selector: selector,
Profile: *profile,
})
}
if len(selections) == 0 {
return nil, false, nil
}
return selections, true, nil
}
func (r *runtimeRunner) runMultiProfile(ctx context.Context, invocation executor.Invocation, selections []multiProfileSelection) (executor.Result, error) {
previousProfile := authpkg.RuntimeProfile()
defer authpkg.SetRuntimeProfile(previousProfile)
entries := make([]any, 0, len(selections))
succeeded := 0
failed := 0
for _, selection := range selections {
authpkg.SetRuntimeProfile(selection.Profile.CorpID)
result, err := r.runSingle(ctx, cloneInvocation(invocation), false)
entry := map[string]any{
"selector": selection.Selector,
"corpId": selection.Profile.CorpID,
"corpName": selection.Profile.CorpName,
"ok": err == nil,
}
if err != nil {
failed++
entry["error"] = multiProfileErrorPayload(err)
} else {
succeeded++
if payload := multiProfileResultPayload(result); payload != nil {
entry["result"] = payload
}
if result.Response != nil {
if endpoint, ok := result.Response["endpoint"]; ok {
entry["endpoint"] = endpoint
}
}
}
entries = append(entries, entry)
}
invocation.Implemented = true
return executor.Result{
Invocation: invocation,
Response: map[string]any{
"content": map[string]any{
"success": failed == 0,
"multiProfile": true,
"summary": map[string]any{
"total": len(selections),
"succeeded": succeeded,
"failed": failed,
},
"profiles": entries,
},
},
}, nil
}
func cloneInvocation(invocation executor.Invocation) executor.Invocation {
cloned := invocation
if invocation.Params != nil {
cloned.Params = make(map[string]any, len(invocation.Params))
for key, value := range invocation.Params {
cloned.Params[key] = value
}
}
return cloned
}
func multiProfileResultPayload(result executor.Result) any {
if result.Response == nil {
return nil
}
if content, ok := result.Response["content"]; ok {
return content
}
return result.Response
}
func multiProfileErrorPayload(err error) map[string]any {
payload := map[string]any{
"message": err.Error(),
}
var typed *apperrors.Error
if errors.As(err, &typed) {
payload["category"] = string(typed.Category)
if typed.Reason != "" {
payload["reason"] = typed.Reason
}
if typed.Operation != "" {
payload["operation"] = typed.Operation
}
if code := typed.ExitCode(); code != 0 {
payload["exitCode"] = code
}
}
return payload
}
// handleCatalogMiss decides what to do when discovery catalog does not cover the
// requested product / tool and no `directRuntimeEndpoint` match fired earlier.
//
@@ -333,6 +486,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
invocation.CanonicalProduct, invocation.Tool, endpoint, version, authToken != "", timeoutSec)
if invocation.DryRun {
// Emit a wukong-aligned human-readable preview on stderr so the dry-run
// surface advertises the resolved MCP arguments without polluting the
// stdout payload (which stays valid JSON in --format json mode). Mirrors
// wukong's "Arguments: {...}" dry-run line; stderr keeps it out of the
// machine-readable channel.
if argsJSON, err := json.Marshal(invocation.Params); err == nil {
fmt.Fprintf(os.Stderr, "DRY-RUN Arguments: %s\n", argsJSON)
}
return executor.Result{
Invocation: invocation,
Response: map[string]any{
@@ -492,6 +653,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}
invocation.Implemented = true
// Align with wukong's response envelope: stamp a top-level success=true on
// map payloads that don't already carry a success flag. Business errors
// (success=false) are intercepted above, so reaching here means the call
// succeeded. Additive only — existing keys are never overwritten.
if callResult.Content != nil {
if _, has := callResult.Content["success"]; !has {
callResult.Content["success"] = true
}
}
response := map[string]any{
"endpoint": transport.RedactURL(endpoint),
"content": callResult.Content,
@@ -586,28 +756,40 @@ func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string {
// Cached token state for process lifetime
var (
cachedRuntimeToken string
cachedRuntimeTokenOnce sync.Once
cachedRuntimeTokenMu sync.Mutex
cachedRuntimeTokens = map[string]string{}
)
// getCachedRuntimeToken returns a cached access token, loading it only once per process.
// This avoids repeated Keychain access which takes ~70ms each time.
func getCachedRuntimeToken(ctx context.Context) string {
cachedRuntimeTokenOnce.Do(func() {
loadStart := time.Now()
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
cacheKey := strings.TrimSpace(authpkg.RuntimeProfile())
if cacheKey == "" {
cacheKey = "__default__"
}
cachedRuntimeTokenMu.Lock()
if token := cachedRuntimeTokens[cacheKey]; token != "" {
cachedRuntimeTokenMu.Unlock()
return token
}
cachedRuntimeTokenMu.Unlock()
configDir := defaultConfigDir()
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
slog.Error(tokenErr.Error())
return
}
if token != "" {
cachedRuntimeToken = token
}
})
return cachedRuntimeToken
loadStart := time.Now()
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
configDir := defaultConfigDir()
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
slog.Error(tokenErr.Error())
return ""
}
if token == "" {
return ""
}
cachedRuntimeTokenMu.Lock()
cachedRuntimeTokens[cacheKey] = token
cachedRuntimeTokenMu.Unlock()
return token
}
// generateExecutionID returns a random 16-char hex string used to correlate
@@ -622,8 +804,9 @@ func generateExecutionID() string {
// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access.
// This should be called after login/logout operations.
func ResetRuntimeTokenCache() {
cachedRuntimeTokenOnce = sync.Once{}
cachedRuntimeToken = ""
cachedRuntimeTokenMu.Lock()
defer cachedRuntimeTokenMu.Unlock()
cachedRuntimeTokens = map[string]string{}
}
func newRuntimeContentScanner() safety.Scanner {
@@ -696,20 +879,21 @@ func resolveIdentityHeaders() map[string]string {
if sessionID == "" {
sessionID = os.Getenv(envRewindSessionID)
}
// Resolve the agent_code (accuracy-first; unknown hosts -> custom) and the
// per-(machine × agent_code) instance id. This is what makes agent_code
// actually report a value: previously it was sent only when the host
// injected DINGTALK_DWS_AGENTCODE (empty ~99.98% of the time), so the
// gateway logged no agent_code at all. DetectAgentCode always yields a code.
// Resolve the agent_code (accuracy-first; unknown hosts stay empty) and the
// per-(machine × agent_code) instance id when a code is known. Synthetic
// fallbacks must not be sent because PAT authorization checks use the same
// header as their grant key.
//
// 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.
// - x-dws-agent-instance-id is NEW: the per-(machine × agent_code) id,
// sent only when x-dingtalk-dws-agent-code is non-empty.
// 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)
if agentInstanceID := id.ResolveAgentID(defaultConfigDir(), agentCode, agentCodeSig); agentInstanceID != "" {
headers["x-dws-agent-instance-id"] = agentInstanceID
}
// 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
@@ -739,6 +923,9 @@ func resolveIdentityHeaders() map[string]string {
if fn := edition.Get().MergeHeaders; fn != nil {
headers = fn(headers)
}
if fn := edition.Get().EnterpriseCredentialHeaders; fn != nil {
headers = fn(headers)
}
return headers
}
+9 -10
View File
@@ -320,12 +320,12 @@ func TestRuntimeRunnerInjectsAuthTokenFromFlag(t *testing.T) {
func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv(authpkg.AgentCodeEnv, " cursor ")
t.Setenv(authpkg.AgentCodeEnv, " QoderWork ")
t.Setenv(authpkg.AgentCodeEnvCompat, "")
headers := resolveIdentityHeaders()
if got := headers["x-dingtalk-dws-agent-code"]; got != "cursor" {
t.Fatalf("x-dingtalk-dws-agent-code = %q, want cursor", got)
if got := headers["x-dingtalk-dws-agent-code"]; got != "QoderWork" {
t.Fatalf("x-dingtalk-dws-agent-code = %q, want QoderWork", got)
}
}
@@ -377,14 +377,13 @@ func TestResolveIdentityHeadersIgnoresReversedAgentCodeEnv(t *testing.T) {
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)
// declaration and no host signature, agent_code stays empty rather than
// falling back to a synthetic key.
if got, ok := headers["x-dingtalk-dws-agent-code"]; ok {
t.Fatalf("x-dingtalk-dws-agent-code = %q, want header omitted", got)
}
if got != authpkg.AgentCodeCustom {
t.Fatalf("x-dingtalk-dws-agent-code = %q, want %q (fallback)", got, authpkg.AgentCodeCustom)
if got, ok := headers["x-dws-agent-instance-id"]; ok {
t.Fatalf("x-dws-agent-instance-id = %q, want header omitted when agent_code is empty", got)
}
}
+30 -1
View File
@@ -123,7 +123,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
if filterErr != nil {
return filterErr
}
multiSkillNames = filtered
// dws-shared carries the global rules every product skill declares as a
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
}
if !autoYes {
@@ -160,6 +162,33 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
// bundle in skills/multi/ (e.g. dingtalk-aitable, dingtalk-calendar).
const multiSkillPrefix = "dingtalk-"
// multiSharedSkill is the shared, non-product skill that every per-product
// skill declares as a PREREQUISITE. It must always be installed in multi mode
// regardless of --skill / --exclude, otherwise the product skills reference a
// dws-shared that was never installed.
const multiSharedSkill = "dws-shared"
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
func ensureMandatorySharedSkill(selected, all []string) []string {
hasShared := false
for _, n := range all {
if n == multiSharedSkill {
hasShared = true
break
}
}
if !hasShared {
return selected
}
for _, n := range selected {
if n == multiSharedSkill {
return selected
}
}
return append([]string{multiSharedSkill}, selected...)
}
// normalizeMultiSkillName accepts either the short form (aitable) or the
// full form (dingtalk-aitable) and returns the canonical full form.
// Empty input returns "". Comparison is case-insensitive.
+13 -10
View File
@@ -23,7 +23,7 @@
// 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.
// - Anything unidentified stays empty — never guess or synthesize a PAT key.
// - Deliberately NOT used: TERM_PROGRAM (reports the terminal, e.g. iTerm,
// not the agent host) and fuzzy parent-process name matching.
package auth
@@ -33,7 +33,8 @@ import (
"strings"
)
// AgentCodeCustom is the honest fallback for any host we cannot identify.
// AgentCodeCustom is the literal code a host may explicitly declare for a
// custom integration. It is not used as an implicit fallback.
const AgentCodeCustom = "custom"
// hostSignature is a verified env fingerprint for a known agent host. EnvKeys
@@ -66,7 +67,7 @@ var knownSignatures = []hostSignature{
// 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.
// gemini"). They therefore resolve to empty 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.
@@ -78,7 +79,7 @@ var knownSignatures = []hostSignature{
// 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.
// bundles map; everything else (iTerm, Terminal, ...) falls through to empty.
//
// macOS-only signal: __CFBundleIdentifier does not exist on Linux/Windows, so
// this map is simply a no-op there (os.Getenv returns "").
@@ -96,11 +97,11 @@ var bundleIDToCode = map[string]string{
// 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)
// T4 unresolved -> empty (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
return v, "env:" + name
}
// T1: verified per-agent env signature (most specific — wins over the IDE
@@ -127,8 +128,8 @@ func DetectAgentCode() (code string, signal string) {
}
}
// T4: unknown host — honest fallback, no guessing.
return AgentCodeCustom, "fallback"
// T4: unknown host — leave agent_code empty, no guessing.
return "", ""
}
// normalizeAgentCode maps host-declared names/brands to canonical agent_code
@@ -140,11 +141,13 @@ func normalizeAgentCode(raw string) string {
s = strings.ReplaceAll(s, " ", "")
switch s {
case "":
return AgentCodeCustom
return ""
case "claude", "claude-code", "claude_code", "claudecode":
return "claudecode"
case "qoder", "qoderwork":
case "qoder":
return "qoder"
case "qoderwork":
return "QoderWork"
case "workbuddy", "work-buddy":
return "workbuddy"
case "visualstudiocode", "code", "code-oss", "vscode":
+16 -17
View File
@@ -38,10 +38,10 @@ func clearAgentCodeEnv(t *testing.T) {
func TestDetectAgentCode_HostDeclaration_T0(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv(AgentCodeEnv, "Qoder")
t.Setenv(AgentCodeEnv, "QoderWork")
code, sig := DetectAgentCode()
if code != "qoder" {
t.Fatalf("want qoder, got %q", code)
if code != "QoderWork" {
t.Fatalf("want verbatim QoderWork, got %q", code)
}
if !strings.HasPrefix(sig, "env:"+AgentCodeEnv) {
t.Fatalf("want env signal, got %q", sig)
@@ -119,25 +119,24 @@ func TestDetectAgentCode_BundleID_T3(t *testing.T) {
}
}
// An unknown bundle id (e.g. a plain terminal) must NOT be labeled — falls to
// custom.
func TestDetectAgentCode_UnknownBundleIsCustom(t *testing.T) {
// An unknown bundle id (e.g. a plain terminal) must NOT be labeled.
func TestDetectAgentCode_UnknownBundleIsEmpty(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)
if code != "" {
t.Fatalf("unknown bundle must be empty, got %q", code)
}
}
func TestDetectAgentCode_Fallback_Custom(t *testing.T) {
func TestDetectAgentCode_FallbackEmpty(t *testing.T) {
clearAgentCodeEnv(t)
code, sig := DetectAgentCode()
if code != AgentCodeCustom {
t.Fatalf("want custom, got %q", code)
if code != "" {
t.Fatalf("want empty code, got %q", code)
}
if sig != "fallback" {
t.Fatalf("want fallback, got %q", sig)
if sig != "" {
t.Fatalf("want empty signal, got %q", sig)
}
}
@@ -147,8 +146,8 @@ func TestDetectAgentCode_IgnoresNoise(t *testing.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)
if code != "" {
t.Fatalf("noise must not decide agent_code; want empty, got %q", code)
}
}
@@ -172,11 +171,11 @@ func TestNormalizeAgentCode(t *testing.T) {
"claude": "claudecode",
"Claude-Code": "claudecode",
"CLAUDECODE": "claudecode",
"Qoderwork": "qoder",
"Qoderwork": "QoderWork",
"WorkBuddy": "workbuddy",
"Visual Studio Code": "vscode",
"Cursor": "cursor",
"": AgentCodeCustom,
"": "",
"some-new-ide": "some-new-ide",
}
for in, want := range cases {
+57
View File
@@ -330,6 +330,63 @@ func TestBuildTokenData_DefaultExpiry(t *testing.T) {
}
}
func TestParseMCPTokenResponseIncludesCorpName(t *testing.T) {
provider := &OAuthProvider{}
data, err := provider.parseMCPTokenResponse([]byte(`{
"accessToken": "access-123",
"refreshToken": "refresh-456",
"expiresIn": 7200,
"corpId": "ding123",
"corpName": "钉钉(中国)信息技术有限公司"
}`))
if err != nil {
t.Fatalf("parseMCPTokenResponse() error = %v", err)
}
if data.CorpID != "ding123" {
t.Fatalf("corp id = %q, want ding123", data.CorpID)
}
if data.CorpName != "钉钉(中国)信息技术有限公司" {
t.Fatalf("corp name = %q, want 钉钉(中国)信息技术有限公司", data.CorpName)
}
}
func TestParseMCPTokenResponseCorpNameFallbacks(t *testing.T) {
provider := &OAuthProvider{}
for _, tc := range []struct {
name string
body string
want string
}{
{
name: "snake",
body: `{"accessToken":"access","refreshToken":"refresh","expiresIn":7200,"corpId":"ding123","corp_name":"Snake Corp"}`,
want: "Snake Corp",
},
{
name: "orgName",
body: `{"accessToken":"access","refreshToken":"refresh","expiresIn":7200,"corpId":"ding123","orgName":"Org Corp"}`,
want: "Org Corp",
},
} {
t.Run(tc.name, func(t *testing.T) {
data, err := provider.parseMCPTokenResponse([]byte(tc.body))
if err != nil {
t.Fatalf("parseMCPTokenResponse() error = %v", err)
}
if data.CorpName != tc.want {
t.Fatalf("corp name = %q, want %q", data.CorpName, tc.want)
}
})
}
}
func TestBuildAuthURLIncludesTargetCorpID(t *testing.T) {
authURL := buildAuthURL("client-id", "http://127.0.0.1:1234/callback", "ding-target")
if !strings.Contains(authURL, "corpId=ding-target") {
t.Fatalf("auth URL missing target corpId: %s", authURL)
}
}
func buildTokenDataFromResponse(resp tokenResponse) *TokenData {
if resp.AccessToken == "" {
return nil
@@ -29,6 +29,13 @@ func TestClassifyDenialReason(t *testing.T) {
},
want: "channel_required",
},
{
name: "error ENTERPRISE_NOT_AUTHORIZED",
status: &CLIAuthStatus{
ErrorCode: "ENTERPRISE_NOT_AUTHORIZED",
},
want: "enterprise_not_authorized",
},
{
name: "error NO_AUTH",
status: &CLIAuthStatus{
+8
View File
@@ -276,6 +276,14 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请升级到最新版本的 CLI 后重试。"))
_, _ = fmt.Fprintln(p.output(), "")
return nil, errors.New(i18n.T("当前组织已开启渠道管控,请升级到最新版本的 CLI 后重试"))
case "enterprise_not_authorized":
msg := i18n.T("本次请求未通过企业安全认证")
if authStatus != nil && strings.TrimSpace(authStatus.ErrorMsg) != "" {
msg = strings.TrimSpace(authStatus.ErrorMsg)
}
_, _ = fmt.Fprintln(p.output(), dfRed("⚠️ "+msg))
_, _ = fmt.Fprintln(p.output(), "")
return nil, errors.New(msg)
case "no_auth":
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 认证已失效")))
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请执行 dws auth 重新登录。"))
+42
View File
@@ -0,0 +1,42 @@
// 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 (
"net/http"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// applyEditionEnterpriseCredentialHeaders injects overlay-provided enterprise
// credential headers (e.g. x-dws-enterprise-credential) into MCP control-plane
// and OAuth proxy requests.
func applyEditionEnterpriseCredentialHeaders(req *http.Request) {
if req == nil {
return
}
fn := edition.Get().EnterpriseCredentialHeaders
if fn == nil {
return
}
merged := fn(nil)
for k, v := range merged {
k = strings.TrimSpace(k)
v = strings.TrimSpace(v)
if k != "" && v != "" {
req.Header.Set(k, v)
}
}
}
+3 -3
View File
@@ -134,11 +134,11 @@ func (id *Identity) machineSeed() string {
// 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.
// per-agent_code statistics possible. An empty agentCode has no per-agent
// identity and returns empty.
func (id *Identity) ResolveAgentID(configDir, agentCode, signal string) string {
if agentCode == "" {
agentCode = AgentCodeCustom
return ""
}
if id.Agents == nil {
id.Agents = make(map[string]*AgentEntry)
+3 -4
View File
@@ -74,13 +74,12 @@ func TestResolveAgentID_IdempotentAndPersisted(t *testing.T) {
}
}
func TestResolveAgentID_EmptyAgentCodeGoesCustom(t *testing.T) {
func TestResolveAgentID_EmptyAgentCodeReturnsEmpty(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)
if got != "" {
t.Fatalf("empty agent_code must not derive an instance id, got %q", got)
}
}
+53 -3
View File
@@ -17,6 +17,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
@@ -30,6 +31,24 @@ var (
// SaveTokenDataKeychain saves TokenData to the platform keychain.
// This is the new secure storage method using random master key.
func SaveTokenDataKeychain(data *TokenData) error {
return saveTokenDataKeychainAccount(keychain.AccountToken, data)
}
// TokenAccountForCorpID returns the keychain account used for a corp-bound token.
func TokenAccountForCorpID(corpID string) string {
return keychain.AccountToken + ":" + strings.TrimSpace(corpID)
}
// SaveTokenDataKeychainForCorpID saves TokenData to a corp-scoped keychain slot.
func SaveTokenDataKeychainForCorpID(corpID string, data *TokenData) error {
corpID = strings.TrimSpace(corpID)
if corpID == "" {
return fmt.Errorf("corpId is required for profile token storage")
}
return saveTokenDataKeychainAccount(TokenAccountForCorpID(corpID), data)
}
func saveTokenDataKeychainAccount(account string, data *TokenData) error {
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshal token data: %w", err)
@@ -41,7 +60,7 @@ func SaveTokenDataKeychain(data *TokenData) error {
}
}()
if err := keychain.Set(keychain.Service, keychain.AccountToken, string(jsonData)); err != nil {
if err := keychain.Set(keychain.Service, account, string(jsonData)); err != nil {
return fmt.Errorf("save to keychain: %w", err)
}
return nil
@@ -49,12 +68,25 @@ func SaveTokenDataKeychain(data *TokenData) error {
// LoadTokenDataKeychain loads TokenData from the platform keychain.
func LoadTokenDataKeychain() (*TokenData, error) {
jsonStr, err := keychain.Get(keychain.Service, keychain.AccountToken)
return loadTokenDataKeychainAccount(keychain.AccountToken)
}
// LoadTokenDataKeychainForCorpID loads TokenData from a corp-scoped keychain slot.
func LoadTokenDataKeychainForCorpID(corpID string) (*TokenData, error) {
corpID = strings.TrimSpace(corpID)
if corpID == "" {
return nil, fmt.Errorf("corpId is required for profile token storage")
}
return loadTokenDataKeychainAccount(TokenAccountForCorpID(corpID))
}
func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
jsonStr, err := keychain.Get(keychain.Service, account)
if err != nil {
return nil, fmt.Errorf("load from keychain: %w", err)
}
if jsonStr == "" {
return nil, fmt.Errorf("no token data in keychain")
return nil, fmt.Errorf("no token data in keychain account %q", account)
}
var data TokenData
@@ -69,11 +101,29 @@ func DeleteTokenDataKeychain() error {
return keychain.Remove(keychain.Service, keychain.AccountToken)
}
// DeleteTokenDataKeychainForCorpID removes TokenData from a corp-scoped keychain slot.
func DeleteTokenDataKeychainForCorpID(corpID string) error {
corpID = strings.TrimSpace(corpID)
if corpID == "" {
return fmt.Errorf("corpId is required for profile token storage")
}
return keychain.Remove(keychain.Service, TokenAccountForCorpID(corpID))
}
// TokenDataExistsKeychain checks if token data exists in keychain.
func TokenDataExistsKeychain() bool {
return keychain.Exists(keychain.Service, keychain.AccountToken)
}
// TokenDataExistsKeychainForCorpID checks if a corp-scoped token exists.
func TokenDataExistsKeychainForCorpID(corpID string) bool {
corpID = strings.TrimSpace(corpID)
if corpID == "" {
return false
}
return keychain.Exists(keychain.Service, TokenAccountForCorpID(corpID))
}
// EnsureMigration performs one-time migration from legacy .data to keychain.
// This should be called early in the auth flow (e.g., during GetAccessToken).
// The migration is idempotent and thread-safe.
+145 -6
View File
@@ -18,11 +18,13 @@ import (
"context"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"net/url"
"os"
"slices"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
@@ -143,9 +145,13 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
updated.CorpID = data.CorpID
updated.UserID = data.UserID
updated.UserName = data.UserName
updated.CorpName = data.CorpName
if updated.CorpName == "" {
updated.CorpName = data.CorpName
}
if err := SaveTokenData(p.configDir, updated); err != nil {
// Refresh runs under lockedRefresh's dual-layer lock; use the lock-free
// saver to avoid re-acquiring the non-reentrant lock (deadlock).
if err := saveTokenDataLocked(p.configDir, updated); err != nil {
return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
}
return updated, nil
@@ -185,9 +191,13 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
updated.CorpID = data.CorpID
updated.UserID = data.UserID
updated.UserName = data.UserName
updated.CorpName = data.CorpName
if updated.CorpName == "" {
updated.CorpName = data.CorpName
}
if err := SaveTokenData(p.configDir, updated); err != nil {
// Refresh runs under lockedRefresh's dual-layer lock; use the lock-free
// saver to avoid re-acquiring the non-reentrant lock (deadlock).
if err := saveTokenDataLocked(p.configDir, updated); err != nil {
return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
}
return updated, nil
@@ -203,6 +213,7 @@ func (p *OAuthProvider) postJSON(ctx context.Context, endpoint string, body any)
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
applyEditionEnterpriseCredentialHeaders(req)
client := p.httpClient
if client == nil {
@@ -259,7 +270,7 @@ func (p *OAuthProvider) parseTokenResponse(body []byte) (*TokenData, error) {
}
// parseMCPTokenResponse parses token response from MCP proxy.
// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "..."}
// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "...", "corpName": "..."}
func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
var resp struct {
AccessToken string `json:"accessToken"`
@@ -267,6 +278,9 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
PersistentCode string `json:"persistentCode"`
ExpiresIn int64 `json:"expiresIn"`
CorpID string `json:"corpId"`
CorpName string `json:"corpName"`
CorpNameSnake string `json:"corp_name"`
OrgName string `json:"orgName"`
// Error fields (when request fails)
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
@@ -293,6 +307,7 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
ExpiresAt: now.Add(time.Duration(expiresIn) * time.Second),
RefreshExpAt: now.Add(config.DefaultRefreshTokenLifetime),
CorpID: resp.CorpID,
CorpName: firstNonEmpty(resp.CorpName, resp.CorpNameSnake, resp.OrgName),
}
if resp.PersistentCode != "" {
data.PersistentCode = resp.PersistentCode
@@ -300,7 +315,16 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
return data, nil
}
func buildAuthURL(clientID, redirectURI string) string {
func firstNonEmpty(values ...string) string {
for _, v := range values {
if trimmed := strings.TrimSpace(v); trimmed != "" {
return trimmed
}
}
return ""
}
func buildAuthURL(clientID, redirectURI, targetCorpID string) string {
params := url.Values{
"client_id": {clientID},
"redirect_uri": {redirectURI},
@@ -308,6 +332,9 @@ func buildAuthURL(clientID, redirectURI string) string {
"scope": {DefaultScopes},
"prompt": {"consent"},
}
if targetCorpID = strings.TrimSpace(targetCorpID); targetCorpID != "" {
params.Set("corpId", targetCorpID)
}
return AuthorizeURL + "?" + params.Encode()
}
@@ -1118,6 +1145,112 @@ const channelDeniedHTML = `<!doctype html>
</body>
</html>`
const enterpriseDeniedHTML = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>钉钉 CLI</title>
<style>
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
padding: 20px;
}
.card {
height: 600px;
width: 480px;
border-radius: 16px;
background: #ffffff;
box-sizing: border-box;
border: 1px solid #f2f2f6;
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.12);
padding: 32px 24px 24px;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.lock-icon {
width: 120px;
height: 120px;
margin: 0 auto;
object-fit: contain;
display: block;
}
h1 {
margin: 8px 0 0;
font-family:
"PingFang SC",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif;
font-size: 18px;
font-weight: 600;
line-height: 44px;
text-align: center;
letter-spacing: normal;
color: #181c1f;
}
p {
margin: 0;
font-family:
"PingFang SC",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif;
font-size: 14px;
font-weight: normal;
line-height: 21px;
text-align: center;
letter-spacing: normal;
color: rgba(24, 28, 31, 0.6);
}
</style>
</head>
<body>
<div class="card">
<img
class="lock-icon"
src="https://img.alicdn.com/imgextra/i4/O1CN01fS3xxz1vbzZSGjbe0_!!6000000006192-2-tps-480-480.png"
alt="lock icon"
/>
<h1>企业安全认证未通过</h1>
<p>__ENTERPRISE_DENIED_MSG__</p>
</div>
</body>
</html>`
// defaultEnterpriseDeniedMsg is shown when the server returns no errorMsg.
const defaultEnterpriseDeniedMsg = "本次请求未通过企业安全认证"
// renderEnterpriseDeniedHTML injects the server-provided denial message (falling
// back to the default text) into the enterprise-denied page. The message is
// HTML-escaped before insertion.
func renderEnterpriseDeniedHTML(serverMsg string) string {
msg := strings.TrimSpace(serverMsg)
if msg == "" {
msg = defaultEnterpriseDeniedMsg
}
return strings.ReplaceAll(enterpriseDeniedHTML, "__ENTERPRISE_DENIED_MSG__", html.EscapeString(msg)+" 此页面可以关闭。")
}
// CLIAuthStatus represents the response from /cli/cliAuthEnabled API.
type CLIAuthStatus struct {
Success bool `json:"success"`
@@ -1154,6 +1287,9 @@ func classifyDenialReason(status *CLIAuthStatus, currentChannel string) string {
if status.ErrorCode == "CHANNEL_REQUIRED" {
return "channel_required"
}
if status.ErrorCode == "ENTERPRISE_NOT_AUTHORIZED" {
return "enterprise_not_authorized"
}
if status.ErrorCode == "NO_AUTH" {
return "no_auth"
}
@@ -1243,6 +1379,7 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s
if ch := os.Getenv("DWS_CHANNEL"); ch != "" {
req.Header.Set("x-dws-channel", ch)
}
applyEditionEnterpriseCredentialHeaders(req)
client := p.httpClient
if client == nil {
@@ -1294,6 +1431,7 @@ func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespo
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("x-user-access-token", accessToken)
applyEditionEnterpriseCredentialHeaders(req)
resp, err := oauthHTTPClient.Do(req)
if err != nil {
@@ -1341,6 +1479,7 @@ func doSendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("x-user-access-token", accessToken)
applyEditionEnterpriseCredentialHeaders(req)
resp, err := oauthHTTPClient.Do(req)
if err != nil {
+28 -8
View File
@@ -23,6 +23,7 @@ import (
"net"
"net/http"
"os"
"strings"
"sync"
"time"
@@ -37,12 +38,13 @@ var oauthHTTPClient = &http.Client{
// OAuthProvider handles the DingTalk OAuth 2.0 authorization code flow.
type OAuthProvider struct {
configDir string
clientID string
logger *slog.Logger
Output io.Writer
httpClient *http.Client
NoBrowser bool
configDir string
clientID string
logger *slog.Logger
Output io.Writer
httpClient *http.Client
NoBrowser bool
TargetCorpID string
}
// NewOAuthProvider creates a new OAuth provider.
@@ -149,6 +151,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
err error
cliAuthDisabled bool
denialReason string
errorMsg string // server-provided errorMsg from /cli/cliAuthEnabled
}
resultCh := make(chan callbackResult, 1)
errCh := make(chan error, 1)
@@ -269,6 +272,13 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
}
cliAuthEnabled := denialReason == ""
// Server-provided errorMsg (nil-safe), surfaced both on the page and to
// the terminal so portal can update copy without releasing the CLI.
serverMsg := ""
if authStatus != nil {
serverMsg = authStatus.ErrorMsg
}
// Update CLI auth disabled state
callbackTokenMu.Lock()
callbackAuthDisabled = !cliAuthEnabled
@@ -283,6 +293,8 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_, _ = fmt.Fprint(w, accessDeniedHTML)
case denialReason == "channel_not_allowed" || denialReason == "channel_required":
_, _ = fmt.Fprint(w, channelDeniedHTML)
case denialReason == "enterprise_not_authorized":
_, _ = fmt.Fprint(w, renderEnterpriseDeniedHTML(serverMsg))
default:
_, _ = fmt.Fprint(w, notEnabledHTML)
}
@@ -292,7 +304,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
}
// Notify main goroutine with full result
select {
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled, denialReason: denialReason}:
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled, denialReason: denialReason, errorMsg: serverMsg}:
default:
}
})
@@ -397,7 +409,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_ = server.Shutdown(shutCtx)
}()
authURL := buildAuthURL(p.clientID, redirectURI)
authURL := buildAuthURL(p.clientID, redirectURI, p.TargetCorpID)
if p.logger != nil {
p.logger.Debug("authorization URL", "url", authURL)
}
@@ -443,6 +455,11 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
return nil, errors.New(i18n.T("您不在该组织的 CLI 授权人员范围内,请联系组织管理员将您加入授权名单"))
case "channel_not_allowed", "channel_required":
return nil, errors.New(i18n.T("当前渠道未获得该组织授权,或组织已开启渠道管控,请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI"))
case "enterprise_not_authorized":
if msg := strings.TrimSpace(result.errorMsg); msg != "" {
return nil, errors.New(msg)
}
return nil, errors.New(i18n.T("本次请求未通过企业安全认证"))
}
_, _ = fmt.Fprintln(p.output(), "")
@@ -547,9 +564,12 @@ func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
if rErr == nil {
return refreshed.AccessToken, nil
}
_ = MarkProfileStatus(p.configDir, data.CorpID, ProfileStatusExpired)
if p.logger != nil {
p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr)
}
} else {
_ = MarkProfileStatus(p.configDir, data.CorpID, ProfileStatusExpired)
}
return "", errors.New(i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"))
+4 -1
View File
@@ -52,6 +52,9 @@ func PortableAuthTargetPopulated(configDir string) bool {
if TokenDataExistsKeychain() {
return true
}
if _, err := os.Stat(ProfilesPath(configDir)); err == nil {
return true
}
if _, err := os.Stat(filepath.Join(configDir, "app.json")); err == nil {
return true
}
@@ -199,7 +202,7 @@ func ImportPortableAuthBundle(configDir string, r io.Reader) (PortableImportRepo
func portableConfigFiles(configDir string) ([]string, error) {
var files []string
patterns := []string{"app*.json", "mcp_url", "terminal_url"}
patterns := []string{"app*.json", profilesJSONFile, "mcp_url", "terminal_url"}
for _, pattern := range patterns {
matches, err := filepath.Glob(filepath.Join(configDir, pattern))
if err != nil {
+73
View File
@@ -138,3 +138,76 @@ func TestPortableAuthBundleRoundTripPreservesRefreshToken(t *testing.T) {
t.Fatalf("imported app config = %#v, want client ID preserved", cfg)
}
}
func TestPortableAuthBundleRoundTripPreservesProfiles(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
SetRuntimeProfile("")
t.Cleanup(func() { SetRuntimeProfile("") })
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
sourceConfig := filepath.Join(t.TempDir(), ".dws")
tokenA := &TokenData{
AccessToken: "access-a",
RefreshToken: "refresh-a",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
CorpID: "corp_a",
CorpName: "A Org",
ClientID: "client-a",
}
tokenB := &TokenData{
AccessToken: "access-b",
RefreshToken: "refresh-b",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
CorpID: "corp_b",
CorpName: "B Org",
ClientID: "client-b",
}
if err := SaveTokenData(sourceConfig, tokenA); err != nil {
t.Fatalf("SaveTokenData(A) error = %v", err)
}
if err := SaveTokenData(sourceConfig, tokenB); err != nil {
t.Fatalf("SaveTokenData(B) error = %v", err)
}
var bundle bytes.Buffer
if err := ExportPortableAuthBundle(sourceConfig, &bundle); err != nil {
t.Fatalf("ExportPortableAuthBundle() error = %v", err)
}
targetKeychain := filepath.Join(t.TempDir(), "target-keychain")
t.Setenv(keychain.StorageDirEnv, targetKeychain)
targetConfig := filepath.Join(t.TempDir(), ".dws")
if _, err := ImportPortableAuthBundle(targetConfig, bytes.NewReader(bundle.Bytes())); err != nil {
t.Fatalf("ImportPortableAuthBundle() error = %v", err)
}
cfg, err := LoadProfiles(targetConfig)
if err != nil {
t.Fatalf("LoadProfiles() after import error = %v", err)
}
if cfg.PrimaryProfile != "corp_a" || cfg.CurrentProfile != "corp_b" || cfg.PreviousProfile != "corp_a" {
t.Fatalf("profiles after import = %#v", cfg)
}
if len(cfg.Profiles) != 2 {
t.Fatalf("profiles len = %d, want 2: %#v", len(cfg.Profiles), cfg.Profiles)
}
loadedA, err := LoadTokenDataForProfile(targetConfig, "corp_a")
if err != nil {
t.Fatalf("LoadTokenDataForProfile(A) after import error = %v", err)
}
if loadedA.AccessToken != "access-a" {
t.Fatalf("profile A token = %q, want access-a", loadedA.AccessToken)
}
loadedB, err := LoadTokenDataForProfile(targetConfig, "corp_b")
if err != nil {
t.Fatalf("LoadTokenDataForProfile(B) after import error = %v", err)
}
if loadedB.AccessToken != "access-b" {
t.Fatalf("profile B token = %q, want access-b", loadedB.AccessToken)
}
}
+678
View File
@@ -0,0 +1,678 @@
// 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 (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
// withProfilesLock runs fn while holding the auth dual-layer lock (process +
// cross-process file lock) so that all read-modify-write cycles on
// profiles.json and the legacy token mirror are serialized.
//
// The lock is NOT reentrant. fn must only call the lock-free *Locked variants;
// calling a public (locking) function from within fn would deadlock. Paths that
// already hold the lock (e.g. OAuthProvider.lockedRefresh and the read path
// reached from it) must likewise call the lock-free variants directly.
func withProfilesLock(configDir string, fn func() error) error {
lock, err := AcquireDualLock(context.Background(), configDir)
if err != nil {
return err
}
defer lock.Release()
return fn()
}
const profilesJSONFile = "profiles.json"
const (
ProfileStatusActive = "active"
ProfileStatusExpired = "expired"
ProfileStatusRevoked = "revoked"
)
// ProfilesConfig stores non-sensitive profile metadata. Token material stays in keychain.
type ProfilesConfig struct {
Version int `json:"version"`
PrimaryProfile string `json:"primaryProfile,omitempty"`
CurrentProfile string `json:"currentProfile,omitempty"`
PreviousProfile string `json:"previousProfile,omitempty"`
Profiles []Profile `json:"profiles,omitempty"`
}
// Profile is a logged-in DingTalk organization identity.
type Profile struct {
Name string `json:"name"`
CorpID string `json:"corpId"`
CorpName string `json:"corpName,omitempty"`
UserID string `json:"userId,omitempty"`
UserName string `json:"userName,omitempty"`
ClientID string `json:"clientId,omitempty"`
Status string `json:"status,omitempty"`
AuthorizedDomains []string `json:"authorizedDomains,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
RefreshExpAt string `json:"refreshExpAt,omitempty"`
LastLoginAt string `json:"lastLoginAt,omitempty"`
LastUsedAt string `json:"lastUsedAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
var (
runtimeProfileMu sync.RWMutex
runtimeProfile string
)
// SetRuntimeProfile sets a process-local one-shot profile override.
func SetRuntimeProfile(profile string) {
runtimeProfileMu.Lock()
defer runtimeProfileMu.Unlock()
runtimeProfile = strings.TrimSpace(profile)
}
// RuntimeProfile returns the process-local one-shot profile override.
func RuntimeProfile() string {
runtimeProfileMu.RLock()
defer runtimeProfileMu.RUnlock()
return runtimeProfile
}
// ProfilesPath returns the profile metadata path for a config dir.
func ProfilesPath(configDir string) string {
return filepath.Join(configDir, profilesJSONFile)
}
// LoadProfiles reads profiles.json. A missing file returns an empty config.
func LoadProfiles(configDir string) (*ProfilesConfig, error) {
path := ProfilesPath(configDir)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &ProfilesConfig{Version: 1}, nil
}
return nil, fmt.Errorf("read profiles: %w", err)
}
var cfg ProfilesConfig
if err := json.Unmarshal(data, &cfg); err != nil {
// Corrupt file (e.g. an interrupted concurrent write): quarantine it and
// rebuild an empty config so the CLI can self-heal (auth reset / re-login)
// instead of being permanently locked out by an unreadable profiles.json.
quarantine := path + ".corrupt-" + time.Now().Format("20060102-150405.000")
_ = os.Rename(path, quarantine)
return &ProfilesConfig{Version: 1}, nil
}
normalizeProfilesConfig(&cfg)
return &cfg, nil
}
// SaveProfiles writes profiles.json atomically.
func SaveProfiles(configDir string, cfg *ProfilesConfig) error {
if cfg == nil {
cfg = &ProfilesConfig{}
}
normalizeProfilesConfig(cfg)
if err := os.MkdirAll(configDir, config.DirPerm); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal profiles: %w", err)
}
data = append(data, '\n')
path := ProfilesPath(configDir)
// Per-write random temp name: a fixed "profiles.json.tmp" lets two
// concurrent writers interleave into the same temp file and rename a
// corrupted result into place.
tmp := path + "." + uuid.New().String() + ".tmp"
if err := os.WriteFile(tmp, data, config.FilePerm); err != nil {
return fmt.Errorf("write profiles tmp: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("rename profiles: %w", err)
}
return nil
}
// EnsureProfilesMigration initializes profiles.json from the legacy auth-token slot when needed.
// EnsureProfilesMigration migrates a legacy single-slot token into the
// profiles registry. It acquires the lock; call ensureProfilesMigrationLocked
// from contexts that already hold it (refresh / read paths).
func EnsureProfilesMigration(configDir string) error {
return withProfilesLock(configDir, func() error {
return ensureProfilesMigrationLocked(configDir)
})
}
func ensureProfilesMigrationLocked(configDir string) error {
cfg, err := LoadProfiles(configDir)
if err != nil {
return err
}
if len(cfg.Profiles) > 0 {
return nil
}
if !TokenDataExistsKeychain() {
return nil
}
data, err := LoadTokenDataKeychain()
if err != nil || data == nil || strings.TrimSpace(data.CorpID) == "" {
return nil
}
if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
return err
}
return upsertProfileFromToken(configDir, cfg, data, false)
}
// UpsertProfileFromToken updates profiles.json after a successful login or refresh.
func UpsertProfileFromToken(configDir string, data *TokenData) error {
return UpsertProfileFromTokenWithCurrent(configDir, data, true)
}
// UpsertProfileFromTokenWithCurrent updates profiles.json and optionally makes
// the token's corp the persistent current profile.
func UpsertProfileFromTokenWithCurrent(configDir string, data *TokenData, makeCurrent bool) error {
return withProfilesLock(configDir, func() error {
return upsertProfileFromTokenWithCurrentLocked(configDir, data, makeCurrent)
})
}
func upsertProfileFromTokenWithCurrentLocked(configDir string, data *TokenData, makeCurrent bool) error {
cfg, err := LoadProfiles(configDir)
if err != nil {
return err
}
return upsertProfileFromToken(configDir, cfg, data, makeCurrent)
}
func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenData, makeCurrent bool) error {
if data == nil {
return nil
}
corpID := strings.TrimSpace(data.CorpID)
if corpID == "" {
return nil
}
normalizeProfilesConfig(cfg)
now := time.Now().Format(time.RFC3339)
idx := profileIndexByCorpID(cfg, corpID)
if idx < 0 {
profile := Profile{
Name: chooseProfileName(cfg, data),
CorpID: corpID,
CorpName: strings.TrimSpace(data.CorpName),
UserID: strings.TrimSpace(data.UserID),
UserName: strings.TrimSpace(data.UserName),
ClientID: strings.TrimSpace(data.ClientID),
Status: ProfileStatusActive,
ExpiresAt: timeOrRFC3339(data.ExpiresAt),
RefreshExpAt: timeOrRFC3339(data.RefreshExpAt),
LastLoginAt: now,
LastUsedAt: now,
UpdatedAt: now,
}
cfg.Profiles = append(cfg.Profiles, profile)
} else {
p := &cfg.Profiles[idx]
if shouldRefreshProfileName(p, data) {
p.Name = chooseProfileName(cfg, data)
}
if v := strings.TrimSpace(data.CorpName); v != "" {
p.CorpName = v
}
if v := strings.TrimSpace(data.UserID); v != "" {
p.UserID = v
}
if v := strings.TrimSpace(data.UserName); v != "" {
p.UserName = v
}
if v := strings.TrimSpace(data.ClientID); v != "" {
p.ClientID = v
}
p.Status = ProfileStatusActive
p.ExpiresAt = timeOrRFC3339(data.ExpiresAt)
p.RefreshExpAt = timeOrRFC3339(data.RefreshExpAt)
p.LastLoginAt = now
p.LastUsedAt = now
p.UpdatedAt = now
}
if cfg.PrimaryProfile == "" {
cfg.PrimaryProfile = corpID
}
if makeCurrent && cfg.CurrentProfile != corpID {
if cfg.CurrentProfile != "" {
cfg.PreviousProfile = cfg.CurrentProfile
}
cfg.CurrentProfile = corpID
}
if cfg.CurrentProfile == "" {
cfg.CurrentProfile = corpID
}
return SaveProfiles(configDir, cfg)
}
// ResolveProfile returns a profile selected by name/corpId or by current/primary fallback.
func ResolveProfile(configDir, selector string) (*Profile, error) {
if err := ensureProfilesMigrationLocked(configDir); err != nil {
return nil, err
}
cfg, err := LoadProfiles(configDir)
if err != nil {
return nil, err
}
selector = strings.TrimSpace(selector)
if selector != "" {
p := findProfile(cfg, selector)
if p == nil {
return nil, fmt.Errorf("profile %q not found", selector)
}
return p, nil
}
if p := findProfile(cfg, cfg.CurrentProfile); p != nil {
return p, nil
}
if p := findProfile(cfg, cfg.PrimaryProfile); p != nil {
return p, nil
}
return nil, nil
}
func resolveProfileForLoad(configDir, selector string) (*Profile, error) {
if err := ensureProfilesMigrationLocked(configDir); err != nil {
return nil, err
}
cfg, err := LoadProfiles(configDir)
if err != nil {
return nil, err
}
selector = strings.TrimSpace(selector)
if selector != "" {
p := findProfile(cfg, selector)
if p == nil {
return nil, fmt.Errorf("profile %q not found", selector)
}
return p, nil
}
for _, candidate := range []string{cfg.CurrentProfile, cfg.PrimaryProfile} {
if p := findProfile(cfg, candidate); p != nil && TokenDataExistsKeychainForCorpID(p.CorpID) {
return p, nil
}
}
if p := findProfile(cfg, cfg.CurrentProfile); p != nil {
return p, nil
}
if p := findProfile(cfg, cfg.PrimaryProfile); p != nil {
return p, nil
}
return nil, nil
}
// SetCurrentProfile persists the selected current profile.
func SetCurrentProfile(configDir, selector string) (*Profile, error) {
var result *Profile
err := withProfilesLock(configDir, func() error {
p, e := setCurrentProfileLocked(configDir, selector)
result = p
return e
})
return result, err
}
func setCurrentProfileLocked(configDir, selector string) (*Profile, error) {
if err := ensureProfilesMigrationLocked(configDir); err != nil {
return nil, err
}
cfg, err := LoadProfiles(configDir)
if err != nil {
return nil, err
}
p := findProfile(cfg, selector)
if p == nil {
return nil, fmt.Errorf("profile %q not found", strings.TrimSpace(selector))
}
if cfg.CurrentProfile != p.CorpID {
if cfg.CurrentProfile != "" {
cfg.PreviousProfile = cfg.CurrentProfile
}
cfg.CurrentProfile = p.CorpID
}
touchProfile(cfg, p.CorpID)
if err := SaveProfiles(configDir, cfg); err != nil {
return nil, err
}
if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
return nil, err
}
return findProfile(cfg, p.CorpID), nil
}
// UsePreviousProfile toggles currentProfile and previousProfile.
func UsePreviousProfile(configDir string) (*Profile, error) {
var result *Profile
err := withProfilesLock(configDir, func() error {
p, e := usePreviousProfileLocked(configDir)
result = p
return e
})
return result, err
}
func usePreviousProfileLocked(configDir string) (*Profile, error) {
if err := ensureProfilesMigrationLocked(configDir); err != nil {
return nil, err
}
cfg, err := LoadProfiles(configDir)
if err != nil {
return nil, err
}
prev := strings.TrimSpace(cfg.PreviousProfile)
if prev == "" {
return nil, fmt.Errorf("previous profile is empty")
}
p := findProfile(cfg, prev)
if p == nil {
return nil, fmt.Errorf("previous profile %q not found", prev)
}
cfg.PreviousProfile, cfg.CurrentProfile = cfg.CurrentProfile, p.CorpID
touchProfile(cfg, p.CorpID)
if err := SaveProfiles(configDir, cfg); err != nil {
return nil, err
}
if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
return nil, err
}
return findProfile(cfg, p.CorpID), nil
}
// RemoveProfile removes a profile from metadata and returns the removed profile.
func RemoveProfile(configDir, selector string) (*Profile, error) {
var result *Profile
err := withProfilesLock(configDir, func() error {
p, e := removeProfileLocked(configDir, selector)
result = p
return e
})
return result, err
}
func removeProfileLocked(configDir, selector string) (*Profile, error) {
cfg, err := LoadProfiles(configDir)
if err != nil {
return nil, err
}
p := findProfile(cfg, selector)
if p == nil {
return nil, fmt.Errorf("profile %q not found", strings.TrimSpace(selector))
}
removed := *p
kept := cfg.Profiles[:0]
for _, profile := range cfg.Profiles {
if profile.CorpID != removed.CorpID {
kept = append(kept, profile)
}
}
cfg.Profiles = kept
if cfg.PrimaryProfile == removed.CorpID {
cfg.PrimaryProfile = firstProfileCorpID(cfg)
}
if cfg.CurrentProfile == removed.CorpID {
cfg.CurrentProfile = cfg.PrimaryProfile
if cfg.CurrentProfile == "" {
cfg.CurrentProfile = firstProfileCorpID(cfg)
}
}
if cfg.PreviousProfile == removed.CorpID {
cfg.PreviousProfile = ""
}
if len(cfg.Profiles) == 0 {
cfg.PrimaryProfile = ""
cfg.CurrentProfile = ""
cfg.PreviousProfile = ""
}
if err := SaveProfiles(configDir, cfg); err != nil {
return nil, err
}
return &removed, nil
}
// MarkProfileStatus updates a profile status if it exists.
func MarkProfileStatus(configDir, corpID, status string) error {
if strings.TrimSpace(corpID) == "" {
return nil
}
return withProfilesLock(configDir, func() error {
return markProfileStatusLocked(configDir, corpID, status)
})
}
func markProfileStatusLocked(configDir, corpID, status string) error {
cfg, err := LoadProfiles(configDir)
if err != nil {
return err
}
p := findProfile(cfg, corpID)
if p == nil {
return nil
}
p.Status = strings.TrimSpace(status)
p.UpdatedAt = time.Now().Format(time.RFC3339)
return SaveProfiles(configDir, cfg)
}
// SyncLegacyTokenMirror mirrors the current profile token into legacy auth-token.
func SyncLegacyTokenMirror(configDir string) error {
return withProfilesLock(configDir, func() error {
return syncLegacyTokenMirrorLocked(configDir)
})
}
func syncLegacyTokenMirrorLocked(configDir string) error {
cfg, err := LoadProfiles(configDir)
if err != nil {
return err
}
hadReadError := false
for _, candidate := range []string{cfg.CurrentProfile, cfg.PrimaryProfile} {
p := findProfile(cfg, candidate)
if p == nil {
continue
}
data, loadErr := LoadTokenDataKeychainForCorpID(p.CorpID)
if loadErr != nil {
// Transient keychain read failure: do NOT touch the existing mirror.
hadReadError = true
continue
}
if data != nil {
if err := SaveTokenDataKeychain(data); err != nil {
return err
}
return WriteTokenMarker(configDir)
}
}
if hadReadError {
// Keep the existing legacy mirror untouched rather than wiping a host
// app's login state just because keychain was momentarily unavailable.
return nil
}
// All candidate profiles confirmed absent (no token): clear the mirror.
_ = DeleteTokenDataKeychain()
_ = DeleteTokenMarker(configDir)
return nil
}
func normalizeProfilesConfig(cfg *ProfilesConfig) {
if cfg == nil {
return
}
cfg.Version = 1
seen := make(map[string]bool, len(cfg.Profiles))
profiles := cfg.Profiles[:0]
for _, p := range cfg.Profiles {
p.CorpID = strings.TrimSpace(p.CorpID)
if p.CorpID == "" || seen[p.CorpID] {
continue
}
seen[p.CorpID] = true
p.Name = strings.TrimSpace(p.Name)
if p.Name == "" {
p.Name = p.CorpID
}
if corpName := strings.TrimSpace(p.CorpName); p.Name == p.CorpID && corpName != "" && !profileNameTakenByOtherCorp(cfg, corpName, p.CorpID) {
p.Name = corpName
}
if p.Status == "" {
p.Status = ProfileStatusActive
}
profiles = append(profiles, p)
}
cfg.Profiles = profiles
if cfg.PrimaryProfile != "" && findProfile(cfg, cfg.PrimaryProfile) == nil {
cfg.PrimaryProfile = ""
}
if cfg.CurrentProfile != "" && findProfile(cfg, cfg.CurrentProfile) == nil {
cfg.CurrentProfile = ""
}
if cfg.PreviousProfile != "" && findProfile(cfg, cfg.PreviousProfile) == nil {
cfg.PreviousProfile = ""
}
if cfg.PrimaryProfile == "" {
cfg.PrimaryProfile = firstProfileCorpID(cfg)
}
if cfg.CurrentProfile == "" {
cfg.CurrentProfile = cfg.PrimaryProfile
}
}
func chooseProfileName(cfg *ProfilesConfig, data *TokenData) string {
base := strings.TrimSpace(data.CorpName)
if base == "" {
base = strings.TrimSpace(data.CorpID)
}
if base == "" {
base = "profile"
}
if !profileNameTakenByOtherCorp(cfg, base, data.CorpID) {
return base
}
suffix := shortCorpID(data.CorpID)
name := base + "-" + suffix
if !profileNameTakenByOtherCorp(cfg, name, data.CorpID) {
return name
}
for i := 2; ; i++ {
candidate := fmt.Sprintf("%s-%s-%d", base, suffix, i)
if !profileNameTakenByOtherCorp(cfg, candidate, data.CorpID) {
return candidate
}
}
}
func shouldRefreshProfileName(p *Profile, data *TokenData) bool {
if p == nil || data == nil {
return false
}
name := strings.TrimSpace(p.Name)
if name == "" {
return true
}
return strings.TrimSpace(data.CorpName) != "" && name == strings.TrimSpace(p.CorpID)
}
func profileNameTakenByOtherCorp(cfg *ProfilesConfig, name, corpID string) bool {
name = strings.TrimSpace(name)
corpID = strings.TrimSpace(corpID)
for _, p := range cfg.Profiles {
if p.CorpID != corpID && p.Name == name {
return true
}
}
return false
}
func findProfile(cfg *ProfilesConfig, selector string) *Profile {
if cfg == nil {
return nil
}
selector = strings.TrimSpace(selector)
if selector == "" {
return nil
}
var corpNameMatch *Profile
for i := range cfg.Profiles {
if cfg.Profiles[i].CorpID == selector || cfg.Profiles[i].Name == selector {
return &cfg.Profiles[i]
}
if strings.TrimSpace(cfg.Profiles[i].CorpName) == selector {
if corpNameMatch != nil {
return nil
}
corpNameMatch = &cfg.Profiles[i]
}
}
return corpNameMatch
}
func profileIndexByCorpID(cfg *ProfilesConfig, corpID string) int {
if cfg == nil {
return -1
}
for i := range cfg.Profiles {
if cfg.Profiles[i].CorpID == corpID {
return i
}
}
return -1
}
func firstProfileCorpID(cfg *ProfilesConfig) string {
if cfg == nil || len(cfg.Profiles) == 0 {
return ""
}
return cfg.Profiles[0].CorpID
}
func touchProfile(cfg *ProfilesConfig, corpID string) {
if p := findProfile(cfg, corpID); p != nil {
now := time.Now().Format(time.RFC3339)
p.LastUsedAt = now
p.UpdatedAt = now
}
}
func timeOrRFC3339(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
func shortCorpID(corpID string) string {
corpID = strings.TrimSpace(corpID)
if len(corpID) <= 8 {
return corpID
}
return corpID[len(corpID)-8:]
}
+167 -10
View File
@@ -22,8 +22,11 @@ import (
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -82,7 +85,7 @@ func WriteTokenMarker(configDir string) error {
if err := os.MkdirAll(configDir, 0o700); err != nil {
return err
}
tmp := filepath.Join(configDir, tokenJSONFile+".tmp")
tmp := filepath.Join(configDir, tokenJSONFile+"."+uuid.New().String()+".tmp")
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
@@ -91,7 +94,10 @@ func WriteTokenMarker(configDir string) error {
// DeleteTokenMarker removes the token.json marker file.
func DeleteTokenMarker(configDir string) error {
return os.Remove(filepath.Join(configDir, tokenJSONFile))
if err := os.Remove(filepath.Join(configDir, tokenJSONFile)); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// SaveTokenData persists TokenData. When an edition hook (SaveToken) is
@@ -99,20 +105,67 @@ func DeleteTokenMarker(configDir string) error {
// to the default keychain-based storage.
func SaveTokenData(configDir string, data *TokenData) error {
if h := edition.Get(); h.SaveToken != nil {
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshaling token data for hook: %w", err)
}
return h.SaveToken(configDir, jsonData)
return saveTokenViaHook(h, configDir, data)
}
return SaveTokenDataKeychain(data)
return withProfilesLock(configDir, func() error {
return saveTokenDataLocked(configDir, data)
})
}
// saveTokenDataLocked performs the keychain + profiles.json + legacy mirror
// writes assuming the auth dual-layer lock is already held. Callers that
// already hold the lock (OAuthProvider refresh path, the legacy secure->keychain
// migration in LoadTokenDataForProfile) must use this instead of SaveTokenData
// to avoid deadlocking on the non-reentrant lock.
func saveTokenDataLocked(configDir string, data *TokenData) error {
if h := edition.Get(); h.SaveToken != nil {
return saveTokenViaHook(h, configDir, data)
}
if data != nil && strings.TrimSpace(data.CorpID) != "" {
if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
return err
}
makeCurrent := strings.TrimSpace(RuntimeProfile()) == ""
if err := upsertProfileFromTokenWithCurrentLocked(configDir, data, makeCurrent); err != nil {
return err
}
if makeCurrent {
if err := SaveTokenDataKeychain(data); err != nil {
return err
}
} else if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
return err
}
return WriteTokenMarker(configDir)
}
if err := SaveTokenDataKeychain(data); err != nil {
return err
}
return WriteTokenMarker(configDir)
}
func saveTokenViaHook(h *edition.Hooks, configDir string, data *TokenData) error {
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshaling token data for hook: %w", err)
}
return h.SaveToken(configDir, jsonData)
}
// LoadTokenData reads TokenData. When an edition hook (LoadToken) is
// registered, it delegates entirely to the hook; otherwise it falls back
// to keychain with legacy .data migration.
func LoadTokenData(configDir string) (*TokenData, error) {
return LoadTokenDataForProfile(configDir, RuntimeProfile())
}
// LoadTokenDataForProfile reads TokenData for a profile selector without mutating
// currentProfile. Empty selector follows the default resolution chain.
func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
if h := edition.Get(); h.LoadToken != nil {
if strings.TrimSpace(profile) != "" {
return nil, fmt.Errorf("profile selection is not supported by the current auth backend")
}
jsonData, err := h.LoadToken(configDir)
if err != nil {
return nil, err
@@ -125,6 +178,28 @@ func LoadTokenData(configDir string) (*TokenData, error) {
}
// Default: keychain with legacy .data migration
selected, err := resolveProfileForLoad(configDir, profile)
if err != nil {
return nil, err
}
if selected != nil {
data, err := LoadTokenDataKeychainForCorpID(selected.CorpID)
if err == nil {
return data, nil
}
if strings.TrimSpace(profile) != "" {
return nil, err
}
// No explicit --profile: `selected` is the resolved current/primary
// profile. Only fall back to the legacy single slot when it belongs to
// the SAME org; otherwise surface the error instead of silently acting
// as a different organization (the legacy mirror may have drifted).
if legacy, lerr := LoadTokenDataKeychain(); lerr == nil && legacy != nil &&
strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) {
return legacy, nil
}
return nil, err
}
if TokenDataExistsKeychain() {
return LoadTokenDataKeychain()
}
@@ -132,7 +207,9 @@ func LoadTokenData(configDir string) (*TokenData, error) {
if err != nil {
return nil, err
}
if err := SaveTokenDataKeychain(data); err == nil {
// One-time legacy secure-store -> keychain migration. This read path may run
// while the refresh lock is already held, so use the lock-free saver.
if err := saveTokenDataLocked(configDir, data); err == nil {
_ = DeleteSecureData(configDir)
}
return data, nil
@@ -142,15 +219,95 @@ func LoadTokenData(configDir string) (*TokenData, error) {
// registered, it delegates entirely to the hook; otherwise it falls back
// to keychain + legacy cleanup.
func DeleteTokenData(configDir string) error {
return DeleteTokenDataForProfile(configDir, RuntimeProfile())
}
// DeleteTokenDataForProfile removes one profile's token data. Empty selector
// removes the current/default profile, falling back to legacy single-slot auth.
func DeleteTokenDataForProfile(configDir, profile string) error {
if h := edition.Get(); h.DeleteToken != nil {
if strings.TrimSpace(profile) != "" {
return fmt.Errorf("profile selection is not supported by the current auth backend")
}
return h.DeleteToken(configDir)
}
return withProfilesLock(configDir, func() error {
return deleteTokenDataForProfileLocked(configDir, profile)
})
}
func deleteTokenDataForProfileLocked(configDir, profile string) error {
selected, err := resolveProfileForLoad(configDir, profile)
if err != nil {
return err
}
if selected != nil {
keychainErr := DeleteTokenDataKeychainForCorpID(selected.CorpID)
_, removeErr := removeProfileLocked(configDir, selected.CorpID)
legacyErr := syncLegacyTokenMirrorLocked(configDir)
secureErr := DeleteSecureData(configDir)
if keychainErr != nil {
return keychainErr
}
if removeErr != nil {
return removeErr
}
if legacyErr != nil {
return legacyErr
}
return secureErr
}
keychainErr := DeleteTokenDataKeychain()
legacyErr := DeleteSecureData(configDir)
markerErr := DeleteTokenMarker(configDir)
if keychainErr != nil {
return keychainErr
}
return legacyErr
if legacyErr != nil {
return legacyErr
}
return markerErr
}
// DeleteAllTokenData removes all profile-scoped and legacy token data.
func DeleteAllTokenData(configDir string) error {
if h := edition.Get(); h.DeleteToken != nil {
return h.DeleteToken(configDir)
}
return withProfilesLock(configDir, func() error {
var firstErr error
// Best-effort: even if profiles.json is unreadable, still clear every
// other slot so the user can always self-heal via auth reset / logout.
if cfg, err := LoadProfiles(configDir); err == nil {
for _, profile := range cfg.Profiles {
if e := DeleteTokenDataKeychainForCorpID(profile.CorpID); e != nil && firstErr == nil {
firstErr = e
}
}
}
if e := os.Remove(ProfilesPath(configDir)); e != nil && !os.IsNotExist(e) && firstErr == nil {
firstErr = e
}
// Sweep any quarantined corrupt-profiles files so they don't accumulate.
if matches, _ := filepath.Glob(ProfilesPath(configDir) + ".corrupt-*"); len(matches) > 0 {
for _, m := range matches {
if e := os.Remove(m); e != nil && !os.IsNotExist(e) && firstErr == nil {
firstErr = e
}
}
}
if e := DeleteTokenDataKeychain(); e != nil && firstErr == nil {
firstErr = e
}
if e := DeleteSecureData(configDir); e != nil && firstErr == nil {
firstErr = e
}
if e := DeleteTokenMarker(configDir); e != nil && firstErr == nil {
firstErr = e
}
return firstErr
})
}
// RevokeTokenRemote calls the appropriate logout/revoke endpoint to invalidate the access token.
+283
View File
@@ -14,6 +14,7 @@
package auth
import (
"os"
"testing"
"time"
@@ -25,8 +26,10 @@ import (
// written by these tests, and removes test data on completion.
func cleanupKeychain(t *testing.T) {
t.Helper()
SetRuntimeProfile("")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
t.Cleanup(func() {
SetRuntimeProfile("")
_ = keychain.Remove(keychain.Service, keychain.AccountToken)
})
}
@@ -127,6 +130,271 @@ func TestTokenOverwrite(t *testing.T) {
}
}
func TestMultiProfileSaveLoadAndSwitch(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
dataA := testToken("at_a", "corp_a", "A Org")
dataB := testToken("at_b", "corp_b", "B Org")
if err := SaveTokenData(configDir, dataA); err != nil {
t.Fatalf("SaveTokenData(A) error = %v", err)
}
if err := SaveTokenData(configDir, dataB); err != nil {
t.Fatalf("SaveTokenData(B) error = %v", err)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.PrimaryProfile != "corp_a" || cfg.CurrentProfile != "corp_b" || cfg.PreviousProfile != "corp_a" {
t.Fatalf("profile pointers = primary %q current %q previous %q", cfg.PrimaryProfile, cfg.CurrentProfile, cfg.PreviousProfile)
}
loadedB, err := LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if loadedB.AccessToken != "at_b" {
t.Fatalf("default token = %q, want at_b", loadedB.AccessToken)
}
loadedA, err := LoadTokenDataForProfile(configDir, "A Org")
if err != nil {
t.Fatalf("LoadTokenDataForProfile(A Org) error = %v", err)
}
if loadedA.AccessToken != "at_a" {
t.Fatalf("profile A token = %q, want at_a", loadedA.AccessToken)
}
if _, err := SetCurrentProfile(configDir, "corp_a"); err != nil {
t.Fatalf("SetCurrentProfile(A) error = %v", err)
}
loadedA, err = LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() after switch error = %v", err)
}
if loadedA.AccessToken != "at_a" {
t.Fatalf("default token after switch = %q, want at_a", loadedA.AccessToken)
}
if _, err := UsePreviousProfile(configDir); err != nil {
t.Fatalf("UsePreviousProfile() error = %v", err)
}
loadedB, err = LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() after previous error = %v", err)
}
if loadedB.AccessToken != "at_b" {
t.Fatalf("default token after previous = %q, want at_b", loadedB.AccessToken)
}
}
func TestRuntimeProfileOverrideDoesNotMutateCurrent(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
if err := SaveTokenData(configDir, testToken("at_a", "corp_a", "A Org")); err != nil {
t.Fatalf("SaveTokenData(A) error = %v", err)
}
if err := SaveTokenData(configDir, testToken("at_b", "corp_b", "B Org")); err != nil {
t.Fatalf("SaveTokenData(B) error = %v", err)
}
if _, err := SetCurrentProfile(configDir, "corp_a"); err != nil {
t.Fatalf("SetCurrentProfile(A) error = %v", err)
}
SetRuntimeProfile("corp_b")
if err := SaveTokenData(configDir, testToken("at_b_refreshed", "corp_b", "B Org")); err != nil {
t.Fatalf("SaveTokenData(B refresh) error = %v", err)
}
SetRuntimeProfile("")
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_a" {
t.Fatalf("current profile = %q, want corp_a", cfg.CurrentProfile)
}
loadedB, err := LoadTokenDataForProfile(configDir, "corp_b")
if err != nil {
t.Fatalf("LoadTokenDataForProfile(B) error = %v", err)
}
if loadedB.AccessToken != "at_b_refreshed" {
t.Fatalf("profile B token = %q, want at_b_refreshed", loadedB.AccessToken)
}
loadedDefault, err := LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if loadedDefault.AccessToken != "at_a" {
t.Fatalf("default token = %q, want at_a", loadedDefault.AccessToken)
}
}
func TestDeleteProfilePreservesOtherProfiles(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
if err := SaveTokenData(configDir, testToken("at_a", "corp_a", "A Org")); err != nil {
t.Fatalf("SaveTokenData(A) error = %v", err)
}
if err := SaveTokenData(configDir, testToken("at_b", "corp_b", "B Org")); err != nil {
t.Fatalf("SaveTokenData(B) error = %v", err)
}
if err := DeleteTokenDataForProfile(configDir, "corp_b"); err != nil {
t.Fatalf("DeleteTokenDataForProfile(B) error = %v", err)
}
if _, err := LoadTokenDataForProfile(configDir, "corp_b"); err == nil {
t.Fatal("LoadTokenDataForProfile(B) error = nil after delete, want failure")
}
loadedA, err := LoadTokenDataForProfile(configDir, "corp_a")
if err != nil {
t.Fatalf("LoadTokenDataForProfile(A) error = %v", err)
}
if loadedA.AccessToken != "at_a" {
t.Fatalf("profile A token = %q, want at_a", loadedA.AccessToken)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if len(cfg.Profiles) != 1 || cfg.CurrentProfile != "corp_a" {
t.Fatalf("profiles after delete = %#v", cfg)
}
}
func TestUpsertProfileFromTokenOverwritesSameCorp(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
first := testToken("at_first", "corp_same", "旧组织名")
if err := SaveTokenData(configDir, first); err != nil {
t.Fatalf("SaveTokenData(first) error = %v", err)
}
second := testToken("at_second", "corp_same", "新组织名")
second.UserID = "user_updated"
second.UserName = "Updated User"
second.ClientID = "client_updated"
if err := SaveTokenData(configDir, second); err != nil {
t.Fatalf("SaveTokenData(second) error = %v", err)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if len(cfg.Profiles) != 1 {
t.Fatalf("profiles len = %d, want 1: %#v", len(cfg.Profiles), cfg.Profiles)
}
profile := cfg.Profiles[0]
if profile.CorpName != "新组织名" {
t.Fatalf("corpName = %q, want 新组织名", profile.CorpName)
}
if profile.UserID != "user_updated" || profile.UserName != "Updated User" || profile.ClientID != "client_updated" {
t.Fatalf("profile metadata was not overwritten: %#v", profile)
}
loaded, err := LoadTokenDataForProfile(configDir, "corp_same")
if err != nil {
t.Fatalf("LoadTokenDataForProfile() error = %v", err)
}
if loaded.AccessToken != "at_second" {
t.Fatalf("access token = %q, want at_second", loaded.AccessToken)
}
}
func TestUpsertProfileFromTokenPromotesCorpIDNameToCorpName(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
first := testToken("at_first", "corp_same", "")
if err := SaveTokenData(configDir, first); err != nil {
t.Fatalf("SaveTokenData(first) error = %v", err)
}
second := testToken("at_second", "corp_same", "新组织名")
if err := SaveTokenData(configDir, second); err != nil {
t.Fatalf("SaveTokenData(second) error = %v", err)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if len(cfg.Profiles) != 1 {
t.Fatalf("profiles len = %d, want 1: %#v", len(cfg.Profiles), cfg.Profiles)
}
if cfg.Profiles[0].Name != "新组织名" {
t.Fatalf("profile name = %q, want 新组织名", cfg.Profiles[0].Name)
}
resolved, err := ResolveProfile(configDir, "新组织名")
if err != nil {
t.Fatalf("ResolveProfile(corpName) error = %v", err)
}
if resolved.CorpID != "corp_same" {
t.Fatalf("resolved corpId = %q, want corp_same", resolved.CorpID)
}
}
func TestLoadProfilesPromotesLegacyCorpIDNameToCorpName(t *testing.T) {
configDir := t.TempDir()
raw := `{
"version": 1,
"primaryProfile": "corp_same",
"currentProfile": "corp_same",
"profiles": [
{
"name": "corp_same",
"corpId": "corp_same",
"corpName": "新组织名"
}
]
}`
if err := os.MkdirAll(configDir, 0o700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(ProfilesPath(configDir), []byte(raw), 0o600); err != nil {
t.Fatalf("WriteFile(profiles.json) error = %v", err)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if len(cfg.Profiles) != 1 {
t.Fatalf("profiles len = %d, want 1", len(cfg.Profiles))
}
if cfg.Profiles[0].Name != "新组织名" {
t.Fatalf("profile name = %q, want 新组织名", cfg.Profiles[0].Name)
}
}
func TestLegacyKeychainMigrationInitializesProfile(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
legacy := testToken("at_legacy", "corp_legacy", "Legacy Org")
if err := SaveTokenDataKeychain(legacy); err != nil {
t.Fatalf("SaveTokenDataKeychain() error = %v", err)
}
loaded, err := LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if loaded.AccessToken != "at_legacy" {
t.Fatalf("loaded token = %q, want at_legacy", loaded.AccessToken)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.PrimaryProfile != "corp_legacy" || cfg.CurrentProfile != "corp_legacy" {
t.Fatalf("profile pointers after migration = %#v", cfg)
}
if !TokenDataExistsKeychainForCorpID("corp_legacy") {
t.Fatal("corp-scoped token should exist after migration")
}
}
func TestTokenDataExistsKeychain(t *testing.T) {
cleanupKeychain(t)
@@ -152,6 +420,21 @@ func TestTokenDataExistsKeychain(t *testing.T) {
}
}
func testToken(accessToken, corpID, corpName string) *TokenData {
now := time.Now().UTC()
return &TokenData{
AccessToken: accessToken,
RefreshToken: "rt_" + accessToken,
ExpiresAt: now.Add(2 * time.Hour),
RefreshExpAt: now.Add(30 * 24 * time.Hour),
CorpID: corpID,
CorpName: corpName,
UserID: "user_" + corpID,
UserName: "User " + corpID,
ClientID: "client_" + corpID,
}
}
func TestTokenValidityChecks(t *testing.T) {
t.Parallel()
+7 -1
View File
@@ -18,6 +18,7 @@ package cobracmd
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
@@ -43,7 +44,7 @@ func FlagChanged(cmd *cobra.Command, name string) bool {
// NewGroupCommand creates a non-leaf parent command that shows help when invoked.
func NewGroupCommand(use, short string) *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: use,
Short: short,
Args: cobra.NoArgs,
@@ -53,6 +54,11 @@ func NewGroupCommand(use, short string) *cobra.Command {
return cmd.Help()
},
}
// Tag as a group container: its RunE only prints help, so cobra's
// Runnable() can't distinguish it from a real leaf — callers that need to
// collapse empty groups rely on this annotation.
cmdutil.MarkGroup(cmd)
return cmd
}
// NewHiddenGroupCommand creates a hidden non-leaf parent command.
+192
View File
@@ -0,0 +1,192 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"encoding/json"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// attendanceScheduleInnerRequired are the fields every scheduleVOS item must
// carry; the backend rejects partial items with an opaque error, so the CLI
// validates them up front (mirrors wukong's attendance.go).
var attendanceScheduleInnerRequired = []string{"userId", "workDate", "classId", "isRest"}
var attendanceGroupTypes = map[string]bool{"FIXED": true, "TURN": true, "NONE": true}
var attendanceApproveTypes = map[string]bool{
"overtime": true, "trip": true, "travel": true, "business_trip": true,
"business-trip": true, "out": true, "leave": true, "patch": true,
"repair_check": true, "repair-check": true,
}
// installAttendanceHook wires attendance-specific PreRunE validators that
// mirror wukong's client-side checks (inner-JSON required fields, group type,
// FIXED conditional requirements, group-update no-op). No-op for other
// products / tools. Preserves any PreRunE NewDirectCommand already installed.
func installAttendanceHook(cmd *cobra.Command, canonicalProduct, toolName string) {
if cmd == nil || strings.TrimSpace(canonicalProduct) != "attendance" {
return
}
var validate func(*cobra.Command) error
switch toolName {
case "generateTurnSchedule":
validate = validateAttendanceScheduleImport
case "create_class_setting":
validate = validateAttendanceClassCreate
case "create_group_setting":
validate = validateAttendanceGroupCreate
case "update_group_setting":
validate = validateAttendanceGroupUpdate
case "update_group_member":
validate = validateAttendanceUpdateMembers
case "save_self_setting":
validate = validateAttendanceSelfSettingSave
case "query_at_approve_template":
validate = validateAttendanceApproveTemplates
default:
return
}
original := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if original != nil {
if err := original(c, args); err != nil {
return err
}
}
return validate(c)
}
}
func attFlagString(cmd *cobra.Command, names ...string) string {
for _, n := range names {
if cmd.Flags().Lookup(n) == nil {
continue
}
if v, err := cmd.Flags().GetString(n); err == nil && strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func validateAttendanceScheduleImport(cmd *cobra.Command) error {
raw := attFlagString(cmd, "scheduleVOS", "schedules")
if raw == "" {
return nil // empty is owned by the required-flag check
}
var items []map[string]any
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil // malformed JSON is owned by a separate check
}
if len(items) == 0 {
return apperrors.NewValidation("--scheduleVOS requires at least one schedule entry (empty array not allowed)")
}
for _, item := range items {
for _, f := range attendanceScheduleInnerRequired {
if _, ok := item[f]; !ok {
return apperrors.NewValidation("missing required field: " + f + "(--scheduleVOS 每个排班项必填)")
}
}
}
return nil
}
func validateAttendanceClassCreate(cmd *cobra.Command) error {
raw := attFlagString(cmd, "class-vo", "TopAtClassVO")
if raw == "" {
return nil
}
var vo map[string]any
if err := json.Unmarshal([]byte(raw), &vo); err != nil {
return nil
}
if _, ok := vo["sections"]; !ok {
return apperrors.NewValidation("missing required field: sections(班次时段,--class-vo 内必填)")
}
return nil
}
func validateAttendanceGroupCreate(cmd *cobra.Command) error {
typ := strings.TrimSpace(attFlagString(cmd, "type"))
if typ != "" && !attendanceGroupTypes[typ] {
return apperrors.NewValidation("考勤组类型不合法:--type 应为 FIXED / TURN / NONE 之一")
}
if typ == "FIXED" {
var vo map[string]any
if raw := attFlagString(cmd, "group-vo", "groupVO"); raw != "" {
_ = json.Unmarshal([]byte(raw), &vo)
}
if vo == nil {
vo = map[string]any{}
}
if _, ok := vo["workDayClassList"]; !ok {
return apperrors.NewValidation("type=FIXED 时 --group-vo 内必填 workDayClassList(工作日班次列表)")
}
if _, ok := vo["defaultClassId"]; !ok {
return apperrors.NewValidation("type=FIXED 时 --group-vo 内必填 defaultClassId(默认班次 ID)")
}
}
return nil
}
func validateAttendanceGroupUpdate(cmd *cobra.Command) error {
if v := strings.TrimSpace(attFlagString(cmd, "enable-outside-check")); v != "" && v != "true" && v != "false" {
return apperrors.NewValidation("--enable-outside-check must be true or false")
}
for _, f := range []string{"name", "type", "owner", "enable-outside-check", "classIds", "group-vo"} {
if fl := cmd.Flags().Lookup(f); fl != nil && cmd.Flags().Changed(f) {
return nil
}
}
return apperrors.NewValidation("至少需要指定一个修改项(--name / --type / --owner / --enable-outside-check / --classIds / --group-vo)")
}
func validateAttendanceUpdateMembers(cmd *cobra.Command) error {
for _, f := range []string{"add-users", "remove-users", "add-extra-users", "remove-extra-users", "add-depts", "remove-depts"} {
if fl := cmd.Flags().Lookup(f); fl != nil && cmd.Flags().Changed(f) {
return nil
}
}
return apperrors.NewValidation("至少需要指定一个变更项(--add-users / --remove-users / --add-extra-users / --remove-extra-users / --add-depts / --remove-depts)")
}
func validateAttendanceSelfSettingSave(cmd *cobra.Command) error {
hasField := false
cmd.Flags().Visit(func(f *pflag.Flag) {
switch f.Name {
case "setting-scene", "user", "yes", "format", "debug", "verbose", "dry-run",
"client-id", "client-secret", "fields", "jq", "mock", "timeout":
// control / identity flags, not setting fields
default:
hasField = true
}
})
if !hasField {
return apperrors.NewValidation("至少需要指定一个设置项(--setting-scene / --user 之外的任一字段)")
}
return nil
}
func validateAttendanceApproveTemplates(cmd *cobra.Command) error {
typ := strings.TrimSpace(attFlagString(cmd, "type"))
if typ != "" && !attendanceApproveTypes[typ] {
return apperrors.NewValidation("无效的审批类型:--type 应为 overtime / leave / patch / trip(travel) / business_trip 之一")
}
return nil
}
+123
View File
@@ -0,0 +1,123 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
// calendarRecurrenceTools are the calendar leaves whose recurrence fields must
// be supplied as a complete set (the MCP backend does not merge partial
// recurrence, so a partial update would silently overwrite the rule). Mirrors
// wukong's calendar.go event create/update validation.
var calendarRecurrenceTools = map[string]bool{
"create_calendar_event": true,
"update_calendar_event": true,
}
// calendarRecurrenceFlags is the full set of --recurrence-* flags; touching any
// of them requires the core structural fields to be present.
var calendarRecurrenceFlags = []string{
"recurrence-type", "recurrence-interval", "recurrence-range-type",
"recurrence-count", "recurrence-end-date", "recurrence-days-of-week",
"recurrence-day-of-month", "recurrence-month", "recurrence-week-index",
"recurrence-first-day-of-week",
}
// installCalendarHook wires calendar-specific PreRunE validators onto leaf
// commands emitted by BuildDynamicCommands. No-op for non-calendar products and
// calendar tools without extra client-side checks. The hook chain preserves the
// PreRunE that NewDirectCommand already installed by invoking it first.
func installCalendarHook(cmd *cobra.Command, canonicalProduct, toolName string) {
if cmd == nil || strings.TrimSpace(canonicalProduct) != "calendar" {
return
}
if !calendarRecurrenceTools[toolName] {
return
}
original := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if original != nil {
if err := original(c, args); err != nil {
return err
}
}
return validateCalendarRecurrence(c)
}
}
// validateCalendarRecurrence refuses a partial recurrence structure. If any
// --recurrence-* flag is set, recurrence-type / interval / range-type must be
// present, and weekly / relativeMonthly patterns require days-of-week. Error
// wording carries the kebab flag names so the messages match wukong and the
// auto-test substring assertions (days-of-week / recurrence-type).
func validateCalendarRecurrence(cmd *cobra.Command) error {
if cmd == nil {
return nil
}
used := false
for _, f := range calendarRecurrenceFlags {
if fl := cmd.Flags().Lookup(f); fl != nil && cmd.Flags().Changed(f) {
used = true
break
}
}
if !used {
return nil
}
recType := strings.TrimSpace(calendarFlagString(cmd, "recurrence-type"))
if recType == "" {
return apperrors.NewValidation(
"recurrence 结构不完整:使用任一 --recurrence-* 时必须整体重传完整循环字段" +
"(至少 --recurrence-type / --recurrence-interval / --recurrence-range-type," +
"MCP 不合并部分字段)")
}
if !calendarFlagSet(cmd, "recurrence-interval") {
return apperrors.NewValidation(
"recurrence 结构不完整:缺少 --recurrence-interval(循环间隔,recurrence 整体必填)")
}
if !calendarFlagSet(cmd, "recurrence-range-type") {
return apperrors.NewValidation(
"recurrence 结构不完整:缺少 --recurrence-range-type(循环范围类型,recurrence 整体必填)")
}
if recType == "weekly" || recType == "relativeMonthly" {
if strings.TrimSpace(calendarFlagString(cmd, "recurrence-days-of-week")) == "" {
return apperrors.NewValidation(
"weekly / relativeMonthly 循环必须提供 --recurrence-days-of-week (daysOfWeek)")
}
}
return nil
}
func calendarFlagString(cmd *cobra.Command, name string) string {
if cmd.Flags().Lookup(name) == nil {
return ""
}
v, _ := cmd.Flags().GetString(name)
return v
}
// calendarFlagSet reports whether a flag was explicitly provided by the user,
// tolerating both string and int (--recurrence-interval) flag kinds.
func calendarFlagSet(cmd *cobra.Command, name string) bool {
fl := cmd.Flags().Lookup(name)
if fl == nil {
return false
}
return cmd.Flags().Changed(name)
}
+94 -1
View File
@@ -51,8 +51,16 @@ var runtimeDefaultWhitelist = map[string]bool{
// detailsByID maps CLI server ID → []DetailTool from the MCP Detail API.
// When provided, tool Short/Long descriptions and typed flags are enriched from Detail API data.
//
// existingTools maps CLI server ID (slug) → set of tool names that server
// actually exposes (from the live tools/list cache). When a server's set is
// present and non-empty, override leaves whose backing MCP tool is missing from
// it are hidden from `--help` (phantom-command guard). When the set is absent or
// empty (cold cache, or a server we have no tools snapshot for) the guard does
// nothing for that server, so an unpopulated cache never blanks the command
// tree. Pass nil to disable the guard entirely.
//
// Conversion rules reference: docs/mcp-to-cli-conversion.md
func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Runner, detailsByID map[string][]market.DetailTool) []*cobra.Command {
func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Runner, detailsByID map[string][]market.DetailTool, existingTools map[string]map[string]struct{}) []*cobra.Command {
type builtCmd struct {
cmd *cobra.Command
parent string // cli.Parent: attach as sub-command of this top-level command
@@ -222,6 +230,25 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
cmd := NewDirectCommand(route, runner)
// §guard.toolexists: keep `--help` honest under envelope/deployment
// drift. When we know the resolved server's live tool set and it does
// NOT contain this leaf's backing tool, the command is a phantom
// (renders in help but fails at invocation with "tool not found"), so
// hide it. Safety rails:
// - only acts when the set is KNOWN and non-empty (absent/empty =
// unknown = keep; a cold tools cache must never blank the tree);
// - skips pipeline leaves, which orchestrate multiple tools and have
// no single backing toolName to check;
// - Hidden (not removed) so the command stays invocable for anyone
// who calls it directly — it just leaves the help surface.
if len(override.Pipeline) == 0 && existingTools != nil {
if known, ok := existingTools[canonicalProduct]; ok && len(known) > 0 {
if _, exists := known[toolName]; !exists {
cmd.Hidden = true
}
}
}
// Enrich flags with typed parameters from Detail API toolRequest JSON Schema.
if dt, ok := detailIndex[toolName]; ok && dt.ToolRequest != "" {
buildFlagsFromDetailSchema(cmd, dt.ToolRequest, override.Flags)
@@ -239,6 +266,20 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
// todo_hooks.go for the full rationale). No-op for non-todo.
installTodoHook(cmd, canonicalProduct, toolName)
// §calendar-hook: reject partial recurrence on event create/update
// (see calendar_hooks.go). No-op for non-calendar.
installCalendarHook(cmd, canonicalProduct, toolName)
// §attendance-hook: inner-JSON required fields, group type and
// FIXED conditional checks (see attendance_hooks.go). No-op for
// non-attendance.
installAttendanceHook(cmd, canonicalProduct, toolName)
// §report-hook: native --contents-file / --contents - (stdin)
// resolution + one-of(contents, contents-file) relaxation (see
// report_hooks.go). No-op for non-report.
installReportHook(cmd, canonicalProduct, toolName)
// §1.4: Add to the right parent group
attachToGroup(rootCmd, override.Group, groupCmds, cmd)
}
@@ -305,9 +346,61 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
for _, name := range topOrder {
commands = append(commands, topLevel[name])
}
// §guard.emptygroups: a group whose every leaf is hidden would still show in
// help as an empty heading (e.g. attendance `vacation`/`overtime` once their
// tools are gone). Collapse those. This runs unconditionally because leaves
// get hidden by TWO independent mechanisms — the runtime tool-existence
// guard above AND envelope `hidden:true` overrides — and an envelope-emptied
// group must collapse even when the guard is inert (cold tools cache). It is
// safe regardless of cache state: hideEmptyGroups only ever hides a group all
// of whose children are already hidden; it never hides a leaf, so it cannot
// blank a tree on its own.
for _, c := range commands {
// Collapse empty sub-groups within each product, but never the product
// root itself: a root can legitimately be empty at this point and gain
// visible leaves later from helper/overlay merges, so hiding it here
// could wrongly drop a whole product from `dws --help`.
for _, sub := range c.Commands() {
hideEmptyGroups(sub)
}
}
return commands
}
// hideEmptyGroups recursively hides group commands whose every subcommand is
// hidden — the collateral of the tool-existence guard emptying a group of all
// its leaves. Returns true if cmd is (now) hidden. A command with no
// subcommands is a leaf: its own Hidden flag is returned unchanged. A group is
// only newly hidden when it HAS subcommands and they are ALL hidden, so a group
// retaining at least one visible leaf always stays visible.
func hideEmptyGroups(cmd *cobra.Command) bool {
subs := cmd.Commands()
if len(subs) == 0 {
// No children. A real leaf stands on its own Hidden flag. A group
// container with no children is empty — this happens when every
// override in a group is `hidden:true` (those leaves are never built,
// leaving the group childless) — so hide it. Group containers and leaves
// both have a RunE, so cobra's Runnable() can't tell them apart; the
// group annotation can.
if cmdutil.IsGroup(cmd) {
cmd.Hidden = true
return true
}
return cmd.Hidden
}
allHidden := true
for _, sub := range subs {
if !hideEmptyGroups(sub) {
allHidden = false
}
}
if allHidden {
cmd.Hidden = true
}
return cmd.Hidden
}
// buildDetailIndex creates a map from toolName → DetailTool for fast lookup.
func buildDetailIndex(tools []market.DetailTool) map[string]market.DetailTool {
idx := make(map[string]market.DetailTool, len(tools))
+38 -38
View File
@@ -77,7 +77,7 @@ func TestBuildDynamicCommands_ParentNesting(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
// Should produce only one top-level command: "chat"
if len(cmds) != 1 {
@@ -130,7 +130,7 @@ func TestBuildDynamicCommands_ParentNotFound(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
// Parent not found, should fall back to top-level
if len(cmds) != 1 {
@@ -169,7 +169,7 @@ func TestBuildDynamicCommands_ShorthandFlag(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 1 {
t.Fatalf("expected 1 cmd, got %d", len(cmds))
}
@@ -212,7 +212,7 @@ func TestBuildDynamicCommands_RequiredFlag(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -253,7 +253,7 @@ func TestBuildDynamicCommands_RequiredIgnoredWhenPositional(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -292,7 +292,7 @@ func TestBuildDynamicCommands_PositionalArg(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -347,7 +347,7 @@ func TestBuildDynamicCommands_PositionalArgInjection(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, captured, nil)
cmds := BuildDynamicCommands(servers, captured, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -399,7 +399,7 @@ func TestBuildDynamicCommands_PositionalWithFlagAliases(t *testing.T) {
},
},
}
cmds := BuildDynamicCommands(servers, captured, nil)
cmds := BuildDynamicCommands(servers, captured, nil, nil)
article := findChild(cmds[0], "article")
if article == nil {
t.Fatal("article group not found")
@@ -552,7 +552,7 @@ func TestBuildDynamicCommands_PositionalArityMixed(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
leaf := findChild(cmds[0], "do")
if leaf == nil {
t.Fatal("do leaf not found")
@@ -606,7 +606,7 @@ func TestBuildDynamicCommands_ServerOverride(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, captured, nil)
cmds := BuildDynamicCommands(servers, captured, nil, nil)
leaf := findChild(cmds[0], "bot-list")
if leaf == nil {
t.Fatal("bot-list leaf not found")
@@ -641,7 +641,7 @@ func TestBuildDynamicCommands_ServerOverrideFallback(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, captured, nil)
cmds := BuildDynamicCommands(servers, captured, nil, nil)
leaf := findChild(cmds[0], "list")
if leaf == nil {
t.Fatal("list leaf not found")
@@ -680,7 +680,7 @@ func TestBuildDynamicCommands_DescriptionOverridesUsage(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -732,7 +732,7 @@ func TestBuildDynamicCommands_OverlayFlagWinsOverDetailSchema(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -775,7 +775,7 @@ func TestBuildDynamicCommands_BodyWrapper(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
create := findChild(cmds[0], "create")
if create == nil {
t.Fatal("create leaf not found")
@@ -858,7 +858,7 @@ func TestBuildDynamicCommands_MutuallyExclusive(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
cmds[0].SetArgs([]string{"list", "--group", "g1", "--user", "u1"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -901,7 +901,7 @@ func TestBuildDynamicCommands_RequireOneOf(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
cmds[0].SetArgs([]string{"list"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -940,7 +940,7 @@ func TestBuildDynamicCommands_RequireOneOfSatisfied(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"list", "--group", "g1"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -974,7 +974,7 @@ func TestBuildDynamicCommands_RedirectTo(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
history := findChild(cmds[0], "history")
if history == nil {
t.Fatal("history stub not found")
@@ -1029,7 +1029,7 @@ func TestBuildDynamicCommands_Hints(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
root := cmds[0]
// history hint attached directly under root.
@@ -1094,7 +1094,7 @@ func TestBuildDynamicCommands_UnknownFlagConstraintSkipped(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
list := findChild(cmds[0], "list")
if list == nil {
t.Fatal("list leaf not found (constraint validation must not abort build)")
@@ -1135,7 +1135,7 @@ func TestBuildDynamicCommands_MultipleAliases_PrimarySet(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search", "--query", "hello"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1193,7 +1193,7 @@ func TestBuildDynamicCommands_MultipleAliases_OnlyAliasSet(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search", "--keyword", "hi"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1234,7 +1234,7 @@ func TestBuildDynamicCommands_MultipleAliases_RequiredErrorWhenNoneSet(t *testin
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1275,7 +1275,7 @@ func TestBuildDynamicCommands_MultipleAliases_PrimaryWinsWhenBothSet(t *testing.
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search", "--query", "primary", "--keyword", "fallback"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1316,7 +1316,7 @@ func TestBuildDynamicCommands_MultipleAliases_MultiAliasChain(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"get", "--user-ids", "u1,u2"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1383,7 +1383,7 @@ func TestBuildDynamicCommands_MultipleAliases_Dedup(t *testing.T) {
// If ApplyBindings panics (duplicate pflag) we fail. Otherwise the cmd
// should build and execute fine.
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search", "--keyword", "ok"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1421,7 +1421,7 @@ func TestBuildDynamicCommands_NoParent(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 2 {
t.Fatalf("expected 2 top-level commands, got %d", len(cmds))
@@ -1457,7 +1457,7 @@ func TestBuildDynamicCommands_ExampleField(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
approval := findChild(cmds[0], "approval")
if approval == nil {
t.Fatal("approval group not found")
@@ -1503,7 +1503,7 @@ func TestApplyBindings_VisibleFlagDefault_String(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
leaf := findChild(cmds[0], "list-forms")
if leaf == nil {
t.Fatal("list-forms leaf not found")
@@ -1548,7 +1548,7 @@ func TestApplyBindings_VisibleFlagDefault_Int(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
leaf := findChild(cmds[0], "list-forms")
if leaf == nil {
t.Fatal("list-forms leaf not found")
@@ -1786,7 +1786,7 @@ func TestBuildDynamicCommands_ParentMergeSameName(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 1 || cmds[0].Name() != "chat" {
t.Fatalf("expected single top-level 'chat', got %d cmds", len(cmds))
}
@@ -1860,7 +1860,7 @@ func TestBuildDynamicCommands_ParentMergeRecursive(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 1 || cmds[0].Name() != "chat" {
t.Fatalf("expected single top-level 'chat', got %d", len(cmds))
}
@@ -1934,7 +1934,7 @@ func TestBuildDynamicCommands_ParentMergeLeafCollision(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 1 {
t.Fatalf("expected 1 top-level, got %d", len(cmds))
}
@@ -1987,7 +1987,7 @@ func TestBuildFlagsFromDetailSchema_FormatEnumAnnotations(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details, nil)
list := findChild(cmds[0], "list")
if list == nil {
t.Fatal("list leaf not found")
@@ -2052,7 +2052,7 @@ func TestBuildDynamicCommands_MapsTo_WithoutTransform(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "# 标题"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -2107,7 +2107,7 @@ func TestBuildDynamicCommands_MapsTo_WithFileReadTransform(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content-file", path})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -2157,7 +2157,7 @@ func TestBuildDynamicCommands_MapsTo_SiblingFlagsExclusiveSetOne(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "literal body"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -2207,7 +2207,7 @@ func TestBuildDynamicCommands_MapsTo_BothSetIsRejectedByCobra(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "x", "--content-file", "/tmp/y"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
+257
View File
@@ -0,0 +1,257 @@
// 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/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/spf13/cobra"
)
// findLeaf returns the first leaf command with the given Use anywhere under
// root (depth-first), or nil.
func findLeaf(root *cobra.Command, name string) *cobra.Command {
for _, c := range root.Commands() {
if c.Name() == name {
return c
}
if got := findLeaf(c, name); got != nil {
return got
}
}
return nil
}
func toolSet(names ...string) map[string]struct{} {
s := make(map[string]struct{}, len(names))
for _, n := range names {
s[n] = struct{}{}
}
return s
}
// attendanceLike builds one server with a real tool and a phantom tool, the
// exact shape of the production drift (e.g. attendance: only a handful of the
// declared overrides map to deployed tools).
func attendanceLike() []market.ServerDescriptor {
return []market.ServerDescriptor{
{
Endpoint: "https://endpoint-attendance",
CLI: market.CLIOverlay{
ID: "attendance",
Command: "attendance",
ToolOverrides: map[string]market.CLIToolOverride{
"get_attendance_summary": {CLIName: "summary"}, // real
"get_overtime_rule": {CLIName: "overtime"}, // phantom
},
},
},
}
}
// TestPhantomGuard_HidesWhenToolSetKnown is the core behaviour: when the live
// tool set is known and non-empty, a leaf whose backing tool is absent is
// hidden from --help while the real leaf stays visible.
func TestPhantomGuard_HidesWhenToolSetKnown(t *testing.T) {
t.Parallel()
existing := map[string]map[string]struct{}{
"attendance": toolSet("get_attendance_summary"), // overtime is NOT deployed
}
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, existing)
summary := findLeaf(cmds[0], "summary")
overtime := findLeaf(cmds[0], "overtime")
if summary == nil || overtime == nil {
t.Fatalf("both leaves should still be registered (invocable); summary=%v overtime=%v", summary, overtime)
}
if summary.Hidden {
t.Error("real command 'summary' must stay visible in --help")
}
if !overtime.Hidden {
t.Error("phantom command 'overtime' must be hidden from --help")
}
}
// TestPhantomGuard_ColdCacheKeepsEverything is the safety rail that the prior
// (source-blind) plan got wrong: with no tool set available (nil map), the
// guard must do nothing — never blank the command tree on a cold cache.
func TestPhantomGuard_ColdCacheKeepsEverything(t *testing.T) {
t.Parallel()
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, nil)
for _, name := range []string{"summary", "overtime"} {
leaf := findLeaf(cmds[0], name)
if leaf == nil {
t.Fatalf("%q should be registered", name)
}
if leaf.Hidden {
t.Errorf("cold cache (nil existingTools) must not hide %q", name)
}
}
}
// TestPhantomGuard_EmptyOrAbsentSetKeepsEverything: an empty set for a server,
// or a server missing from the map entirely, both mean "unknown" — keep all.
func TestPhantomGuard_EmptyOrAbsentSetKeepsEverything(t *testing.T) {
t.Parallel()
cases := []struct {
name string
existing map[string]map[string]struct{}
}{
{"empty set for server", map[string]map[string]struct{}{"attendance": {}}},
{"server absent from map", map[string]map[string]struct{}{"someother": toolSet("x")}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, tc.existing)
for _, name := range []string{"summary", "overtime"} {
leaf := findLeaf(cmds[0], name)
if leaf == nil {
t.Fatalf("%q should be registered", name)
}
if leaf.Hidden {
t.Errorf("%s: must not hide %q when tool set is unknown", tc.name, name)
}
}
})
}
}
// TestPhantomGuard_ServerOverrideRoutesToTargetSet: a leaf with serverOverride
// must be checked against the TARGET server's tool set, not the host's. This is
// what prevents false-flagging legit cross-server routes (contact→hrmregister,
// doc→doc-comment).
func TestPhantomGuard_ServerOverrideRoutesToTargetSet(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-contact",
CLI: market.CLIOverlay{
ID: "contact",
Command: "contact",
ToolOverrides: map[string]market.CLIToolOverride{
// routed to hrmregister; the tool lives there, not in contact
"get_roster": {CLIName: "roster", ServerOverride: "hrmregister"},
},
},
},
}
// contact's own set is empty of get_roster, but hrmregister has it.
existing := map[string]map[string]struct{}{
"contact": toolSet("search_user"),
"hrmregister": toolSet("get_roster"),
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, existing)
roster := findLeaf(cmds[0], "roster")
if roster == nil {
t.Fatal("roster leaf should be registered")
}
if roster.Hidden {
t.Error("serverOverride leaf must resolve against the target server's set and stay visible")
}
}
// TestPhantomGuard_EmptyGroupCollapses: a group all of whose overrides are
// hidden:true (so none of its leaves are built) must itself be hidden from
// help, while a group keeping at least one visible leaf stays. This runs
// regardless of the tools-cache oracle (envelope hidden:true is cache-
// independent), so existingTools is nil here.
func TestPhantomGuard_EmptyGroupCollapses(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-attendance",
CLI: market.CLIOverlay{
ID: "attendance",
Command: "attendance",
Groups: map[string]market.CLIGroupDef{
"vacation": {Description: "假期管理"}, // all leaves hidden -> collapse
"record": {Description: "考勤记录"}, // keeps a visible leaf
},
ToolOverrides: map[string]market.CLIToolOverride{
"get_leave_types": {CLIName: "types", Group: "vacation", Hidden: true},
"get_leave_balance_quota": {CLIName: "balance", Group: "vacation", Hidden: true},
"get_user_attendance_record": {CLIName: "get", Group: "record"},
},
},
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
vacation := findGroup(cmds[0], "vacation")
record := findGroup(cmds[0], "record")
if vacation == nil || record == nil {
t.Fatalf("both groups should exist as commands; vacation=%v record=%v", vacation, record)
}
if !vacation.Hidden {
t.Error("group 'vacation' with only hidden leaves must collapse (be hidden)")
}
if record.Hidden {
t.Error("group 'record' with a visible leaf must stay visible")
}
}
// findGroup returns a direct child of root with the given name (groups attach
// directly under the product root).
func findGroup(root *cobra.Command, name string) *cobra.Command {
for _, c := range root.Commands() {
if c.Name() == name {
return c
}
}
return nil
}
// TestPhantomGuard_PipelineLeafNeverHidden: pipeline leaves orchestrate multiple
// tools and have no single backing toolName, so the guard must skip them even
// when the override key is not a deployed tool.
func TestPhantomGuard_PipelineLeafNeverHidden(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-im",
CLI: market.CLIOverlay{
ID: "im",
Command: "im",
ToolOverrides: map[string]market.CLIToolOverride{
"download_media": {
CLIName: "download-media",
Pipeline: []market.PipelineStep{
{Tool: "get_resource_download_url"},
},
},
},
},
},
}
// download_media itself is not a deployed tool name, but the pipeline is.
existing := map[string]map[string]struct{}{
"im": toolSet("get_resource_download_url"),
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, existing)
dl := findLeaf(cmds[0], "download-media")
if dl == nil {
t.Fatal("download-media leaf should be registered")
}
if dl.Hidden {
t.Error("pipeline leaf must never be hidden by the tool-existence guard")
}
}
+12 -3
View File
@@ -336,7 +336,16 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
Response: resp,
}
if route.OutputTransform != nil && result.Response != nil {
result.Response = route.OutputTransform(result.Response)
// Shape the MCP content payload (the actual data), not the
// {endpoint, content} runtime envelope, so rename/drop/table
// paths resolve against response fields (e.g. result.items)
// rather than the wrapper. Falls back to the whole Response
// when no content map is present (degraded/echo paths).
if content, ok := result.Response["content"].(map[string]any); ok {
result.Response["content"] = route.OutputTransform(content)
} else {
result.Response = route.OutputTransform(result.Response)
}
}
return output.WriteCommandPayload(cmd, result, output.FormatJSON)
}
@@ -742,7 +751,7 @@ func collectSchemaFlags(cmd *cobra.Command, bindings []FlagBinding, params map[s
"json": true, "params": true, "help": true,
"format": true, "fields": true, "jq": true,
"debug": true, "verbose": true, "dry-run": true,
"yes": true, "mock": true, "timeout": true,
"yes": true, "mock": true, "profile": true, "timeout": true,
"client-id": true, "client-secret": true,
}
@@ -862,7 +871,7 @@ func CollectBindings(cmd *cobra.Command, bindings []FlagBinding, existing map[st
if _, ok := existing[binding.Property]; ok {
continue
}
return nil, apperrors.NewValidation(fmt.Sprintf("--%s is required", primaryName))
return nil, apperrors.NewValidation(fmt.Sprintf("missing required flag: --%s is required", primaryName))
}
if !anyChanged {
continue
+2 -2
View File
@@ -83,7 +83,7 @@ func TestBuildDynamicCommandsSurvivesMalformedFlagEnvelope(t *testing.T) {
}
// Must not panic; the command must build and stay executable.
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil, nil)
if len(cmds) != 1 {
t.Fatalf("BuildDynamicCommands() = %d commands, want 1", len(cmds))
}
@@ -120,7 +120,7 @@ func TestBuildDynamicCommandsKeepsFirstShorthand(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil, nil)
boom, _, err := cmds[0].Find([]string{"boom"})
if err != nil {
t.Fatalf("find boom: %v", err)
+3 -1
View File
@@ -288,6 +288,7 @@ func TestCollectSchemaFlagsSkipsGlobalFlags(t *testing.T) {
cmd.Flags().Bool("verbose", false, "Verbose")
cmd.Flags().Bool("dry-run", false, "Dry run")
cmd.Flags().String("format", "json", "Format")
cmd.Flags().String("profile", "", "Profile")
cmd.Flags().String("json", "", "")
cmd.Flags().String("params", "", "")
@@ -296,6 +297,7 @@ func TestCollectSchemaFlagsSkipsGlobalFlags(t *testing.T) {
_ = cmd.Flags().Set("verbose", "true")
_ = cmd.Flags().Set("dry-run", "true")
_ = cmd.Flags().Set("format", "table")
_ = cmd.Flags().Set("profile", "corp_profile")
params := make(map[string]any)
collectSchemaFlags(cmd, nil, params)
@@ -304,7 +306,7 @@ func TestCollectSchemaFlagsSkipsGlobalFlags(t *testing.T) {
t.Errorf("name = %v, want Bob", params["name"])
}
// Global flags should be skipped
for _, skip := range []string{"debug", "verbose", "dry_run", "format"} {
for _, skip := range []string{"debug", "verbose", "dry_run", "format", "profile"} {
if _, exists := params[skip]; exists {
t.Errorf("%s should be skipped (global flag)", skip)
}
+186
View File
@@ -0,0 +1,186 @@
// 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.
// report_hooks.go — CLI-side input resolution for the `report` product.
//
// The envelope publishes `report entry submit` (MCP tool create_report) with a
// `--contents` flag (json_parse, required) and a sibling `--contents-file`
// flag (omitWhen empty, no transform/mapsTo). On its own, `--contents-file`
// therefore goes nowhere: its value maps to the unused `contentsFile` param and
// the real `contents` param stays empty, so a `--contents-file`-only (or
// `--contents -` stdin) submit silently sends `contents: [null]` and the report
// fails. The literal-only `--contents` path works, which is why
// `report create` (the helper, inline-only) succeeds while
// `report entry submit --contents-file` does not.
//
// The wukong reference implementation reads the file/stdin natively inside its
// hand-written cobra RunE (dws-wukong/wukong/products/report.go
// resolveReportContentsFromFlags, priority: --contents-file > --contents -
// (stdin) > --contents '<json>'). The open-source CLI is envelope-driven, so we
// attach the equivalent native resolution as a build-time hook here, mirroring
// AttachReportListReadableEnrichment (which layers wukong-equivalent list
// enrichment onto the same envelope leaves). No discovery-config change is
// needed: the hook populates the real `--contents` flag before the envelope's
// json_parse transform runs, and the broken `contentsFile` override is left
// inert.
//
// Two build-time adjustments make `--contents-file`-only valid:
//
// 1. The envelope marks `--contents` individually required (cobra
// MarkFlagRequired, enforced at parse time, before PreRunE). We clear that
// annotation and instead declare a `contents` / `contents-file` one-of
// group (MarkFlagsOneRequired, validated by ValidateFlagGroups — also
// before PreRunE, but satisfied when either flag is set). Supplying
// neither still errors, now naming both flags.
// 2. A chained PreRunE resolves the chosen source into `--contents` so the
// downstream json_parse transform sees inline JSON regardless of origin.
package compat
import (
"fmt"
"io"
"os"
"strings"
"unicode/utf8"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
// reportContentsMaxBytes caps the contents payload at 10MB, matching the
// wukong upstream limit (dws-wukong/wukong/products/report.go
// reportContentsMaxBytes). Oversized input is rejected rather than truncated.
const reportContentsMaxBytes = 10 * 1024 * 1024
// reportToolsWithContentsFile lists every report toolName whose `--contents` /
// `--contents-file` pair needs native file/stdin resolution. Today only
// create_report (the `report entry submit` leaf) carries the pair.
var reportToolsWithContentsFile = map[string]bool{
"create_report": true,
}
// installReportHook wires report-specific input resolution onto leaf commands
// emitted by BuildDynamicCommands. It is a no-op for non-report products and
// for report tools that do not expose the contents/contents-file pair.
//
// The hook chain preserves the cmd.PreRunE that NewDirectCommand already
// installed (currently validateRequireTogether) by invoking it first.
func installReportHook(cmd *cobra.Command, canonicalProduct, toolName string) {
if cmd == nil {
return
}
if strings.TrimSpace(canonicalProduct) != "report" {
return
}
if !reportToolsWithContentsFile[toolName] {
return
}
contents := cmd.Flags().Lookup("contents")
file := cmd.Flags().Lookup("contents-file")
if contents == nil || file == nil {
// Envelope shape changed (renamed/removed flags) — do not block the
// command; leave whatever the envelope declared untouched.
return
}
// (1) Relax the individually-required `--contents` into a one-of group so
// `--contents-file`-only (or `--contents -`) is accepted. Clearing the
// required annotation must happen before parse-time ValidateRequiredFlags;
// this hook runs at build time, so it does.
if contents.Annotations != nil {
delete(contents.Annotations, cobra.BashCompOneRequiredFlag)
}
cmd.MarkFlagsOneRequired("contents", "contents-file")
// (2) Resolve the chosen source into --contents before the RunE transform.
original := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if original != nil {
if err := original(c, args); err != nil {
return err
}
}
return resolveReportContents(c)
}
}
// resolveReportContents applies the wukong source priority — `--contents-file`
// (file) > `--contents -` (stdin) > `--contents '<json>'` (literal) — and
// writes the resolved JSON string back into the `--contents` flag so the
// downstream json_parse transform decodes it uniformly. When a file or stdin
// source is used, `--contents-file` is cleared so the envelope's omitWhen:empty
// drops the now-redundant param.
func resolveReportContents(cmd *cobra.Command) error {
filePath, _ := cmd.Flags().GetString("contents-file")
if strings.TrimSpace(filePath) != "" {
data, err := readReportContentsFile(filePath)
if err != nil {
return err
}
if err := cmd.Flags().Set("contents", data); err != nil {
return apperrors.NewInternal("failed to set --contents from --contents-file")
}
_ = cmd.Flags().Set("contents-file", "")
return nil
}
raw, _ := cmd.Flags().GetString("contents")
if strings.TrimSpace(raw) == "-" {
data, err := readReportContentsLimited(cmd.InOrStdin(), "--contents -")
if err != nil {
return err
}
if err := cmd.Flags().Set("contents", data); err != nil {
return apperrors.NewInternal("failed to set --contents from stdin")
}
}
return nil
}
// readReportContentsFile opens a file path and reads its contents under the
// 10MB cap and UTF-8 check. Error wording mirrors wukong so agents and humans
// see a stable message across both editions.
func readReportContentsFile(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return "", apperrors.NewValidation(
fmt.Sprintf("--contents-file: file not found: %s", path),
apperrors.WithHint("确认路径存在,且指向一个 JSON 文件"),
)
}
return "", apperrors.NewValidation(fmt.Sprintf("--contents-file: cannot read %s: %v", path, err))
}
defer file.Close()
return readReportContentsLimited(file, fmt.Sprintf("--contents-file %s", path))
}
// readReportContentsLimited reads from r enforcing the 10MB cap and UTF-8
// validity. A LimitReader at cap+1 detects overflow without reading unbounded.
func readReportContentsLimited(r io.Reader, source string) (string, error) {
data, err := io.ReadAll(io.LimitReader(r, int64(reportContentsMaxBytes)+1))
if err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("%s: read failed: %v", source, err))
}
if len(data) > reportContentsMaxBytes {
return "", apperrors.NewValidation(
fmt.Sprintf("%s: contents exceed maximum size of 10MB", source),
apperrors.WithHint("精简内容或拆分为多份日志提交"),
)
}
if !utf8.Valid(data) {
return "", apperrors.NewValidation(fmt.Sprintf("%s: not valid UTF-8", source))
}
return string(data), nil
}
+198
View File
@@ -0,0 +1,198 @@
// 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 (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
// newReportSubmitStub mirrors the leaf command shape emitted by
// BuildDynamicCommands for `report entry submit` (envelope: create_report).
// Only the flags the hook touches are registered. --contents is marked
// required to reproduce the envelope's MarkFlagRequired so the relaxation
// behaviour can be asserted.
func newReportSubmitStub() *cobra.Command {
cmd := &cobra.Command{Use: "submit", RunE: func(*cobra.Command, []string) error { return nil }}
cmd.Flags().String("contents", "", "contents JSON array")
cmd.Flags().String("contents-file", "", "contents JSON file")
cmd.Flags().String("template-id", "", "template id")
_ = cmd.MarkFlagRequired("contents")
return cmd
}
const reportContentsPayload = `[{"key":"今日完成工作","sort":"0","content":"done","contentType":"markdown","type":"1"}]`
func TestResolveReportContents_FromFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "contents.json")
if err := os.WriteFile(path, []byte(reportContentsPayload), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents-file", path); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(file): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents not populated from file: %q", got)
}
// contents-file must be cleared so omitWhen:empty drops the dead param.
if cf, _ := cmd.Flags().GetString("contents-file"); cf != "" {
t.Fatalf("--contents-file should be cleared after resolution, got %q", cf)
}
}
func TestResolveReportContents_FromStdin(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", "-"); err != nil {
t.Fatal(err)
}
cmd.SetIn(strings.NewReader(reportContentsPayload))
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(stdin): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents not populated from stdin: %q", got)
}
}
func TestResolveReportContents_InlineUntouched(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", reportContentsPayload); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(inline): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("inline --contents must be left untouched, got %q", got)
}
}
func TestResolveReportContents_FilePriorityOverInline(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "contents.json")
if err := os.WriteFile(path, []byte(reportContentsPayload), 0o600); err != nil {
t.Fatal(err)
}
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", `[{"stale":"inline"}]`); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("contents-file", path); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents: %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents-file must win over inline --contents, got %q", got)
}
}
func TestResolveReportContents_MissingFileErrors(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents-file", filepath.Join(t.TempDir(), "nope.json")); err != nil {
t.Fatal(err)
}
err := resolveReportContents(cmd)
if err == nil {
t.Fatal("expected error for missing --contents-file path")
}
if !strings.Contains(err.Error(), "file not found") {
t.Fatalf("unexpected error: %v", err)
}
}
// ── installReportHook composition ──────────────────────────────
func TestInstallReportHook_RelaxesRequiredToOneOf(t *testing.T) {
cmd := newReportSubmitStub()
// Before the hook, --contents carries the cobra required annotation.
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] == nil {
t.Fatal("precondition: --contents should start out required")
}
installReportHook(cmd, "report", "create_report")
// After the hook, the individual required annotation must be cleared so a
// --contents-file-only invocation is not rejected at parse time.
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] != nil {
t.Fatal("installReportHook should clear the individual required on --contents")
}
// And a PreRunE must now be installed to resolve the source.
if cmd.PreRunE == nil {
t.Fatal("installReportHook should install a PreRunE")
}
}
func TestInstallReportHook_NoOpForOtherProduct(t *testing.T) {
cmd := newReportSubmitStub()
installReportHook(cmd, "chat", "create_report")
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] == nil {
t.Fatal("non-report product must not touch required annotation")
}
if cmd.PreRunE != nil {
t.Fatal("non-report product must not install a PreRunE")
}
}
func TestInstallReportHook_NoOpForOtherReportTool(t *testing.T) {
cmd := newReportSubmitStub()
installReportHook(cmd, "report", "get_received_report_list")
if cmd.PreRunE != nil {
t.Fatal("non-target report tool must not install a PreRunE")
}
}
func TestInstallReportHook_ChainsExistingPreRunE(t *testing.T) {
cmd := newReportSubmitStub()
originalCalled := false
cmd.PreRunE = func(*cobra.Command, []string) error { originalCalled = true; return nil }
installReportHook(cmd, "report", "create_report")
if err := cmd.Flags().Set("contents", reportContentsPayload); err != nil {
t.Fatal(err)
}
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !originalCalled {
t.Fatal("original PreRunE was dropped")
}
}
func TestInstallReportHook_BailsIfChainedPreRunEFails(t *testing.T) {
cmd := newReportSubmitStub()
cmd.PreRunE = func(*cobra.Command, []string) error { return errors.New("original boom") }
installReportHook(cmd, "report", "create_report")
err := cmd.PreRunE(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "original boom") {
t.Fatalf("expected original PreRunE error to bubble, got %v", err)
}
}
func TestInstallReportHook_NilCmdSafe(t *testing.T) {
installReportHook(nil, "report", "create_report")
}
+121 -3
View File
@@ -30,7 +30,7 @@ import (
// ApplyTransform applies a named transform rule to a value.
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse,
// json_parse_strict, enum_map, file_read, invert_bool, string_to_int64.
// json_parse_strict, enum_map, file_read, invert_bool, parse_bool, string_to_int64.
func ApplyTransform(value any, transform string, args map[string]any) (any, error) {
switch strings.TrimSpace(transform) {
case "":
@@ -49,6 +49,10 @@ func ApplyTransform(value any, transform string, args map[string]any) (any, erro
return transformFileRead(value)
case "invert_bool":
return transformInvertBool(value)
case "parse_bool":
return transformParseBool(value)
case "attendance_class_check_time":
return transformAttendanceClassCheckTime(value)
case "string_to_int64":
return transformStringToInt64(value)
default:
@@ -79,6 +83,77 @@ func transformInvertBool(value any) (any, error) {
}
}
// transformParseBool coerces a CLI string flag into a real JSON boolean so the
// MCP body carries `false`/`true` (not the string "false"/"true" or a swallowed
// zero value). Used by envelope flags that are semantically boolean but must be
// declared as string flags to accept an explicit `false` on the command line
// (cobra bool flags drop the space-form value). Unknown tokens pass through
// unchanged so upstream validators own the error wording.
func transformParseBool(value any) (any, error) {
switch v := value.(type) {
case bool:
return v, nil
case string:
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes", "on":
return true, nil
case "false", "0", "no", "off":
return false, nil
}
return value, nil
default:
return value, nil
}
}
// transformAttendanceClassCheckTime parses a class-VO JSON string and converts
// every "HH:mm" checkTime under sections[*].times[*] and
// setting.topRestTimeList[*] into a Unix-millis number (1970-01-01 HH:mm in
// UTC+8), mirroring wukong's convertClassCheckTime. The MCP backend expects the
// numeric form; the envelope cannot express this nested walk, so it lives here.
func transformAttendanceClassCheckTime(value any) (any, error) {
parsed, err := transformJSONParseStrict(value)
if err != nil {
return nil, err
}
classVO, ok := parsed.(map[string]any)
if !ok {
return parsed, nil
}
cst := time.FixedZone("CST", 8*3600)
convertOne := func(obj map[string]any) {
if ct, ok := obj["checkTime"].(string); ok {
ct = strings.TrimSpace(ct)
if t, err := time.ParseInLocation("2006-01-02 15:04", "1970-01-01 "+ct, cst); err == nil {
obj["checkTime"] = float64(t.UnixMilli())
}
}
}
if sections, ok := classVO["sections"].([]any); ok {
for _, sec := range sections {
if secMap, ok := sec.(map[string]any); ok {
if times, ok := secMap["times"].([]any); ok {
for _, t := range times {
if tMap, ok := t.(map[string]any); ok {
convertOne(tMap)
}
}
}
}
}
}
if setting, ok := classVO["setting"].(map[string]any); ok {
if restList, ok := setting["topRestTimeList"].([]any); ok {
for _, item := range restList {
if itemMap, ok := item.(map[string]any); ok {
convertOne(itemMap)
}
}
}
}
return classVO, nil
}
func transformISO8601ToMillis(value any) (any, error) {
s, ok := toString(value)
if !ok {
@@ -168,6 +243,18 @@ func transformJSONParse(value any) (any, error) {
if s == "" {
return value, nil
}
// @file / @- expansion — read the JSON/YAML payload from a file or stdin
// before parsing. A leading "@" is an unambiguous file sentinel because a
// JSON/YAML value never starts with "@"; this is what the error hint below
// promises and lets long/complex payloads (many records, big cell ranges)
// avoid shell-quoting hell.
s, err := resolveJSONSource(s)
if err != nil {
return nil, err
}
if s == "" {
return value, nil
}
// Strict JSON first — fast path and unambiguous type promotion (numbers
// stay numbers, etc.).
var parsed any
@@ -182,10 +269,32 @@ func transformJSONParse(value any) (any, error) {
return nil, apperrors.NewValidation(
"json_parse: input is not valid JSON or YAML; " +
"quote the whole value and use `[{key: value, ...}]` for ad-hoc input, " +
"or pass `@path/to/file.json` to read from a file",
"or pass `@path/to/file.json` (or `@-` for stdin) to read from a file",
)
}
// resolveJSONSource expands an @file / @- reference used by the json_parse
// transforms. A leading "@" is the file sentinel: "@-" reads stdin, "@<path>"
// reads the file (UTF-8, via transformFileRead). Any value not starting with
// "@" is returned unchanged. JSON/YAML payloads never start with "@", so this
// is unambiguous for structured flags.
func resolveJSONSource(s string) (string, error) {
if !strings.HasPrefix(s, "@") {
return s, nil
}
ref := strings.TrimSpace(s[1:])
if ref == "" {
return "", apperrors.NewValidation(
"json_parse: `@` must be followed by a file path, or `@-` to read from stdin")
}
out, err := transformFileRead(ref)
if err != nil {
return "", err
}
loaded, _ := out.(string)
return strings.TrimSpace(loaded), nil
}
// transformJSONParseStrict is the strict variant of json_parse: only accepts
// well-formed JSON, rejecting input that the YAML fallback would otherwise
// silently coerce to a scalar string. Use when the upstream tool requires a
@@ -199,12 +308,21 @@ func transformJSONParseStrict(value any) (any, error) {
if s == "" {
return value, nil
}
// @file / @- expansion — same sentinel as json_parse (see resolveJSONSource).
s, err := resolveJSONSource(s)
if err != nil {
return nil, err
}
if s == "" {
return value, nil
}
var parsed any
if err := json.Unmarshal([]byte(s), &parsed); err != nil {
return nil, apperrors.NewValidation(
"json_parse_strict: input is not valid JSON; " +
"this transform rejects YAML-style ad-hoc input — quote the whole value " +
"as strict JSON (e.g. '[{\"key\":\"value\"}]') or use `json_parse` for YAML-tolerant parsing",
"as strict JSON (e.g. '[{\"key\":\"value\"}]'), pass `@path/to/file.json` " +
"(or `@-` for stdin), or use `json_parse` for YAML-tolerant parsing",
)
}
return parsed, nil
+59
View File
@@ -127,6 +127,65 @@ func TestJSONParse_InvalidInput(t *testing.T) {
}
}
func TestJSONParse_AtFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "payload.json")
if err := os.WriteFile(path, []byte(`[{"k":"长内容\n多行","n":1}]`), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
got, err := ApplyTransform("@"+path, "json_parse", nil)
if err != nil {
t.Fatalf("json_parse @file: %v", err)
}
arr, ok := got.([]any)
if !ok || len(arr) != 1 {
t.Fatalf("expected 1-element array from @file, got %T %v", got, got)
}
item := arr[0].(map[string]any)
if item["k"] != "长内容\n多行" {
t.Fatalf("@file content mismatch: %v", item)
}
}
func TestJSONParse_AtFileMissing(t *testing.T) {
if _, err := ApplyTransform("@"+filepath.Join(t.TempDir(), "nope.json"), "json_parse", nil); err == nil {
t.Fatal("json_parse @missing-file should error")
}
}
func TestJSONParse_BareAtErrors(t *testing.T) {
_, err := ApplyTransform("@", "json_parse", nil)
if err == nil {
t.Fatal("bare @ should error (needs a path or -)")
}
}
func TestJSONParseStrict_AtFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "p.json")
if err := os.WriteFile(path, []byte(`{"a":[1,2,3]}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
got, err := ApplyTransform("@"+path, "json_parse_strict", nil)
if err != nil {
t.Fatalf("json_parse_strict @file: %v", err)
}
if _, ok := got.(map[string]any); !ok {
t.Fatalf("expected object, got %T", got)
}
}
func TestJSONParse_AtPassthroughNonAt(t *testing.T) {
// A value not starting with "@" must be parsed inline, untouched.
got, err := ApplyTransform(`[{"x":1}]`, "json_parse", nil)
if err != nil {
t.Fatalf("inline json_parse: %v", err)
}
if arr, ok := got.([]any); !ok || len(arr) != 1 {
t.Fatalf("inline parse regressed: %T %v", got, got)
}
}
// TestFileRead_BasicFile exercises the happy path: a UTF-8 file on disk is
// read in full and surfaced as a string value. This is the contract the
// `--content-file ./a.md` flag relies on so the upstream MCP tool sees the
+2 -2
View File
@@ -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/bamboo
// and MCP JSON-RPC endpoints. marketOK controls whether /cli/discovery/apis/cedar
// 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/bamboo", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/cli/discovery/apis/cedar", func(w http.ResponseWriter, r *http.Request) {
if !marketOK {
http.Error(w, "market unavailable", http.StatusInternalServerError)
return
+1 -1
View File
@@ -259,7 +259,7 @@ func newDocsMCPGateway(expectations []docsServerExpectation) *httptest.Server {
mux := http.NewServeMux()
server := httptest.NewServer(mux)
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/cli/discovery/apis/cedar", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
+49 -3
View File
@@ -110,6 +110,7 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
newAitableFieldCreateCommand(runner),
newAitableFieldUpdateCommand(runner),
newAitableFieldDeleteCommand(runner),
newAitableFieldSearchOptionsCommand(runner),
newAitableFieldListAlias(runner),
)
@@ -129,7 +130,13 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
newAitableRecordCreateCommand(runner),
newAitableRecordUpdateCommand(runner),
newAitableRecordBatchUpdateCommand(runner),
newAitableRecordQueryEmptyCommand(runner),
newAitableRecordDeleteCommand(runner),
newAitableRecordHistoryListCommand(runner),
newAitableRecordShareURLCommand(runner),
newAitableRecordUpsertCommand(runner),
newAitableRecordPrimaryDocGetCommand(runner),
newAitableRecordPrimaryDocCreateCommand(runner),
newAitableRecordListAlias(runner),
)
@@ -251,6 +258,7 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
newAitableDashboardUpdateCommand(runner),
newAitableDashboardDeleteCommand(runner),
newAitableDashboardConfigExampleCommand(runner),
newAitableDashboardArrangeCommand(runner),
dashboardShare,
)
@@ -264,15 +272,53 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
viewGet := newAitableViewGetCommand(runner)
viewGet.AddCommand(
newAitableViewGetCardCommand(runner),
newAitableViewGetTimebarCommand(runner),
newAitableViewGetAggregateCommand(runner),
newAitableViewGetFilterCommand(runner),
newAitableViewGetSortCommand(runner),
newAitableViewGetGroupCommand(runner),
newAitableViewGetVisibleFieldsCommand(runner),
newAitableViewGetFieldWidthsCommand(runner),
newAitableViewGetLockCommand(runner),
newAitableViewGetFrozenColsCommand(runner),
newAitableViewGetRowHeightCommand(runner),
newAitableViewGetFillColorRuleCommand(runner),
)
viewUpdate := newAitableViewUpdateCommand(runner)
viewUpdate.AddCommand(
newAitableViewUpdateCardCommand(runner),
newAitableViewUpdateTimebarCommand(runner),
newAitableViewUpdateAggregateCommand(runner),
newAitableViewUpdateFieldWidthsCommand(runner),
newAitableViewUpdateVisibleFieldsCommand(runner),
newAitableViewUpdateFilterCommand(runner),
newAitableViewUpdateSortCommand(runner),
newAitableViewUpdateGroupCommand(runner),
newAitableViewUpdateNameCommand(runner),
newAitableViewUpdateFrozenColsCommand(runner),
newAitableViewUpdateRowHeightCommand(runner),
newAitableViewUpdateFillColorRuleCommand(runner),
)
view.AddCommand(
newAitableViewGetCommand(runner),
viewGet,
newAitableViewListCommand(runner),
newAitableViewCreateCommand(runner),
newAitableViewUpdateCommand(runner),
viewUpdate,
newAitableViewDeleteCommand(runner),
newAitableViewLockCommand(runner),
newAitableViewDuplicateCommand(runner),
)
root.AddCommand(base, table, field, record, newAitableFormCommand(runner), template, attachment, export, importCmd, dashboard, chart, view)
root.AddCommand(
base, table, field, record, newAitableFormCommand(runner),
newAitableWorkflowCommand(runner),
template, attachment, export, importCmd, dashboard, chart, view,
newAitableAdvpermCommand(runner),
newAitableSectionCommand(runner),
)
// 顶层别名:dws aitable search/list/create/info → base search/list/create/get
// 每个 alias 复用现有 constructor,独立 cobra.Command 实例(避免与 base.* 共享 flag 指针)
+51 -10
View File
@@ -130,7 +130,33 @@ func newAitableBaseCreateCommand(runner executor.Runner) *cobra.Command {
if folderID := aitableStringFlag(cmd, "folder-id"); folderID != "" {
params["folderId"] = folderID
}
return runAitableTool(cmd, runner, "create_base", params)
// dry-run 或带模板:保持单步 create_base 语义。
if commandDryRun(cmd) {
return runAitableTool(cmd, runner, "create_base", params)
}
result, err := runAitableProductToolResult(cmd, runner, "aitable", "create_base", params)
if err != nil {
return err
}
// 默认表由服务端按 scenario 决定:wukong 场景建、openClaw 不建。
// 为与 wukong 行为对齐,无模板时在 CLI 侧兜底补建一张默认表,
// 使新 base 立即可用(含 tableId)。输出仍保持原 create_base 结果,
// 不把内部补建步骤暴露给调用方。
if _, hasTemplate := params["templateId"]; !hasTemplate {
if baseID := findStringDeep(result.Response, "baseId", "baseID"); baseID != "" {
if _, terr := runAitableProductToolResult(cmd, runner, "aitable", "create_table", map[string]any{
"baseId": baseID,
"tableName": "表格1",
"fields": []any{map[string]any{
"fieldName": "标题",
"type": "text",
}},
}); terr != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "warning: created base %s but default table creation failed: %v\n", baseID, terr)
}
}
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
@@ -1440,6 +1466,9 @@ func newAitableViewUpdateCommand(runner executor.Runner) *cobra.Command {
if err != nil {
return err
}
if err := normalizeAitableViewConfigBlock(cmd, config); err != nil {
return err
}
params["config"] = config
}
if _, hasName := params["newViewName"]; !hasName {
@@ -1499,6 +1528,14 @@ func runAitableFormTool(cmd *cobra.Command, runner executor.Runner, tool string,
}
func runAitableProductTool(cmd *cobra.Command, runner executor.Runner, product, tool string, params map[string]any) error {
result, err := runAitableProductToolResult(cmd, runner, product, tool, params)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
func runAitableProductToolResult(cmd *cobra.Command, runner executor.Runner, product, tool string, params map[string]any) (executor.Result, error) {
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
product,
@@ -1509,9 +1546,9 @@ func runAitableProductTool(cmd *cobra.Command, runner executor.Runner, product,
if invocation.DryRun {
result, err := runner.Run(cmd.Context(), invocation)
if err != nil {
return err
return executor.Result{}, err
}
return writeCommandPayload(cmd, result)
return result, nil
}
var lastErr error
@@ -1523,7 +1560,7 @@ func runAitableProductTool(cmd *cobra.Command, runner executor.Runner, product,
select {
case <-cmd.Context().Done():
timer.Stop()
return cmd.Context().Err()
return executor.Result{}, cmd.Context().Err()
case <-timer.C:
}
}
@@ -1531,16 +1568,16 @@ func runAitableProductTool(cmd *cobra.Command, runner executor.Runner, product,
result, err := runner.Run(cmd.Context(), invocation)
lastErr = err
if err == nil {
return writeCommandPayload(cmd, result)
return result, nil
}
if !aitableErrorRetryable(err) {
return err
return executor.Result{}, err
}
}
if lastErr != nil {
return lastErr
return executor.Result{}, lastErr
}
return nil
return executor.Result{}, nil
}
const aitableHelperMaxRetries = 3
@@ -1906,11 +1943,15 @@ func normalizeAitableSort(items []any) []any {
}
func parseAitableJSONArray(raw, flagName string) ([]any, error) {
var value []any
var value any
if err := json.Unmarshal([]byte(raw), &value); err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("--%s JSON parse failed: %v", flagName, err))
}
return value, nil
arr, ok := value.([]any)
if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf("--%s must be a JSON array / 数组, got %T", flagName, value))
}
return arr, nil
}
func parseAitableJSONObject(raw, flagName string) (map[string]any, error) {
+52
View File
@@ -1393,3 +1393,55 @@ func singleAitableRecord(t *testing.T, value any) map[string]any {
}
return record
}
// base create 无模板时,CLI 兜底补建一张默认表(与 wukong 场景行为对齐:
// 服务端对 openClaw 不建默认表,CLI 侧补齐使新 base 立即含 tableId)。
func TestAitableBaseCreateAddsDefaultTableWithoutTemplate(t *testing.T) {
t.Parallel()
runner := &aitableSequencedRunner{responses: []map[string]any{
{"data": map[string]any{"baseId": "BASE_001"}},
{"data": map[string]any{"tableId": "TABLE_001"}},
}}
cmd := newAitableBaseCreateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--name", "项目跟踪"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if len(runner.calls) != 2 {
t.Fatalf("calls = %d, want create_base + create_table", len(runner.calls))
}
if got := runner.calls[0].Tool; got != "create_base" {
t.Fatalf("first tool = %q, want create_base", got)
}
if got := runner.calls[1].Tool; got != "create_table" {
t.Fatalf("second tool = %q, want create_table", got)
}
if got := runner.calls[1].Params["baseId"]; got != "BASE_001" {
t.Fatalf("create_table baseId = %#v, want BASE_001", got)
}
}
// base create 带 --template-id 时不补建默认表(模板自带结构)。
func TestAitableBaseCreateWithTemplateSkipsDefaultTable(t *testing.T) {
t.Parallel()
runner := &aitableSequencedRunner{responses: []map[string]any{
{"data": map[string]any{"baseId": "BASE_001"}},
}}
cmd := newAitableBaseCreateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--name", "项目跟踪", "--template-id", "TPL_001"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if len(runner.calls) != 1 {
t.Fatalf("calls = %d, want only create_base", len(runner.calls))
}
if got := runner.calls[0].Tool; got != "create_base" {
t.Fatalf("tool = %q, want create_base", got)
}
}
File diff suppressed because it is too large Load Diff
+420
View File
@@ -0,0 +1,420 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
func executeAitableExtraCommand(t *testing.T, cmd *cobra.Command, args ...string) {
t.Helper()
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
}
type aitableSequencedRunner struct {
calls []executor.Invocation
responses []map[string]any
}
func (r *aitableSequencedRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.calls = append(r.calls, invocation)
var response map[string]any
if idx := len(r.calls) - 1; idx >= 0 && idx < len(r.responses) {
response = r.responses[idx]
}
return executor.Result{Invocation: invocation, Response: response}, nil
}
func TestAitableFieldSearchOptionsRoutesToAitable(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableFieldSearchOptionsCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--field-id", "FIELD_001",
"--keyword", "已",
"--limit", "10",
)
if got := runner.last.CanonicalProduct; got != "aitable" {
t.Fatalf("CanonicalProduct = %q, want aitable", got)
}
if got := runner.last.Tool; got != "search_field_options" {
t.Fatalf("Tool = %q, want search_field_options", got)
}
if got := runner.last.Params["keyword"]; got != "已" {
t.Fatalf("keyword = %#v, want 已", got)
}
if got := runner.last.Params["limit"]; got != 10 {
t.Fatalf("limit = %#v, want 10", got)
}
}
func TestAitableViewGetFieldWidthsProjectsCustomWidthMap(t *testing.T) {
t.Parallel()
runner := &aitableSequencedRunner{responses: []map[string]any{{
"content": map[string]any{
"status": "success",
"data": map[string]any{"views": []any{map[string]any{
"viewId": "VIEW_001",
"viewType": "Grid",
"custom": map[string]any{
"widthMap": map[string]any{"FIELD_001": 240},
},
}}},
},
}}}
cmd := newAitableViewGetFieldWidthsCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--view-id", "VIEW_001",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("output JSON parse error = %v\nstdout:\n%s", err, out.String())
}
data, ok := payload["data"].(map[string]any)
if !ok {
t.Fatalf("data = %#v, want object", payload["data"])
}
if got := data["FIELD_001"]; got != float64(240) {
t.Fatalf("FIELD_001 width = %#v, want 240", got)
}
}
func TestAitableViewUpdateCardDispatchesGalleryConfig(t *testing.T) {
t.Parallel()
runner := &aitableSequencedRunner{responses: []map[string]any{
{"content": map[string]any{
"status": "success",
"data": map[string]any{"views": []any{map[string]any{
"viewId": "VIEW_001",
"viewType": "Gallery",
}}},
}},
{"content": map[string]any{"status": "success"}},
}}
cmd := newAitableViewUpdateCardCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--view-id", "VIEW_001",
"--cover-mode", "custom",
"--cover-field-id", "FIELD_001",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if len(runner.calls) != 2 {
t.Fatalf("calls = %d, want 2", len(runner.calls))
}
update := runner.calls[1]
if update.Tool != "update_view" {
t.Fatalf("second tool = %q, want update_view", update.Tool)
}
config, ok := update.Params["config"].(map[string]any)
if !ok {
t.Fatalf("config = %#v, want object", update.Params["config"])
}
card, ok := config["galleryCard"].(map[string]any)
if !ok {
t.Fatalf("galleryCard = %#v, want object", config["galleryCard"])
}
if got := card["coverMode"]; got != "custom" {
t.Fatalf("coverMode = %#v, want custom", got)
}
}
func TestAitableViewUpdateConfigRoutedKeyHintsSubcommand(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableViewUpdateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--view-id", "VIEW_001",
"--config", `{"frozenColCount":2}`,
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if !strings.Contains(errOut.String(), "frozen-cols") {
t.Fatalf("stderr missing frozen-cols hint:\n%s", errOut.String())
}
}
func TestAitableRecordHistoryListRoutesToHelper(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableRecordHistoryListCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--record-id", "REC_001",
"--offset", "10",
"--limit", "30",
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "query_record_history" {
t.Fatalf("Tool = %q, want query_record_history", got)
}
if got := runner.last.Params["recordId"]; got != "REC_001" {
t.Fatalf("recordId = %#v, want REC_001", got)
}
if got := runner.last.Params["offset"]; got != 10 {
t.Fatalf("offset = %#v, want 10", got)
}
if got := runner.last.Params["limit"]; got != 30 {
t.Fatalf("limit = %#v, want 30", got)
}
}
func TestAitableRecordUpsertAcceptsFieldsAlias(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableRecordUpsertCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--fields", `[{"recordId":"REC_001","cells":{"fld":"updated"}},{"cells":{"fld":"new"}}]`,
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "record_upsert" {
t.Fatalf("Tool = %q, want record_upsert", got)
}
records, ok := runner.last.Params["records"].([]any)
if !ok {
t.Fatalf("records type = %T, want []any", runner.last.Params["records"])
}
if len(records) != 2 {
t.Fatalf("records len = %d, want 2", len(records))
}
}
func TestAitableViewExtraCommandsRouteToExpectedTools(t *testing.T) {
t.Parallel()
t.Run("lock unlock", func(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableViewLockCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--view-id", "VIEW_001",
"--off",
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "lock_or_unlock_view" {
t.Fatalf("Tool = %q, want lock_or_unlock_view", got)
}
if got := runner.last.Params["action"]; got != "unlock" {
t.Fatalf("action = %#v, want unlock", got)
}
})
t.Run("fill color rule", func(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableViewUpdateFillColorRuleCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--view-id", "VIEW_001",
"--json", `[]`,
)
if got := runner.last.CanonicalProduct; got != "aitable" {
t.Fatalf("CanonicalProduct = %q, want aitable", got)
}
if got := runner.last.Tool; got != "set_view_fill_color_rule" {
t.Fatalf("Tool = %q, want set_view_fill_color_rule", got)
}
if formats, ok := runner.last.Params["conditionalFormats"].([]any); !ok || len(formats) != 0 {
t.Fatalf("conditionalFormats = %#v, want empty []any", runner.last.Params["conditionalFormats"])
}
})
}
func TestAitableWorkflowListRoutesToHelper(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableWorkflowListCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--limit", "50",
"--offset", "100",
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "list_workflows" {
t.Fatalf("Tool = %q, want list_workflows", got)
}
if got := runner.last.Params["limit"]; got != 50 {
t.Fatalf("limit = %#v, want 50", got)
}
if got := runner.last.Params["offset"]; got != 100 {
t.Fatalf("offset = %#v, want 100", got)
}
}
func TestAitableRecordQueryEmptyRoutesToHelper(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableRecordQueryEmptyCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--limit", "50",
"--cursor", "CUR_001",
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "query_empty_records" {
t.Fatalf("Tool = %q, want query_empty_records", got)
}
if got := runner.last.Params["limit"]; got != 50 {
t.Fatalf("limit = %#v, want 50", got)
}
if got := runner.last.Params["cursor"]; got != "CUR_001" {
t.Fatalf("cursor = %#v, want CUR_001", got)
}
}
func TestAitableRecordQueryEmptyRejectsOutOfRangeLimit(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableRecordQueryEmptyCommand(runner)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--limit", "200",
})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
if err := cmd.Execute(); err == nil {
t.Fatal("expected error for --limit 200, got nil")
}
if runner.last.Tool != "" {
t.Fatalf("runner should not be called on invalid limit, got tool %q", runner.last.Tool)
}
}
func TestAitableDashboardArrangeRoutesToHelper(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableDashboardArrangeCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--dashboard-id", "DASH_001",
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "align_dashboard" {
t.Fatalf("Tool = %q, want align_dashboard", got)
}
if got := runner.last.Params["dashboardId"]; got != "DASH_001" {
t.Fatalf("dashboardId = %#v, want DASH_001", got)
}
}
func TestAitableAdvpermRoleCreateParsesSubRoles(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableAdvpermRoleCreateCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--name", "市场可读",
"--sub-roles", `[{"targetId":"TABLE_001","targetType":"sheet","authLevel":"read"}]`,
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "create_role" {
t.Fatalf("Tool = %q, want create_role", got)
}
subRoles, ok := runner.last.Params["subRoles"].([]any)
if !ok || len(subRoles) != 1 {
t.Fatalf("subRoles = %#v, want single-item []any", runner.last.Params["subRoles"])
}
}
func TestAitableSectionMoveNodeAllowsRootParent(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableSectionMoveNodeCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--node-id", "NODE_001",
"--new-parent-section-id", "",
"--target-index", "0",
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "move_nsheet_node" {
t.Fatalf("Tool = %q, want move_nsheet_node", got)
}
if got := runner.last.Params["newParentSectionId"]; got != "" {
t.Fatalf("newParentSectionId = %#v, want empty string", got)
}
if got := runner.last.Params["targetIndex"]; got != 0 {
t.Fatalf("targetIndex = %#v, want 0", got)
}
}
+167
View File
@@ -0,0 +1,167 @@
// 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"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/spf13/cobra"
)
func init() {
RegisterPublic(func() Handler { return calendarHandler{} })
}
// calendarHandler contributes the `calendar attendee list|add|delete` group.
// wukong renamed the calendar participant commands to "attendee" (former name:
// participant); the envelope still exposes them under "participant". These
// leaves call the same MCP tools (get/add/remove_calendar_participant) so the
// wukong command surface is aligned without dropping the legacy participant
// path. MergeCommandTree folds the attendee group into the calendar tree.
type calendarHandler struct{}
func (calendarHandler) Name() string { return "calendar" }
func (calendarHandler) Command(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "calendar",
Short: i18n.T("日历"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
}
attendee := &cobra.Command{
Use: "attendee",
Short: i18n.T("参会人管理(与 participant 等价,对齐 wukong 命名)"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
}
attendee.AddCommand(
newCalendarAttendeeListCommand(runner),
newCalendarAttendeeAddCommand(runner),
newCalendarAttendeeDeleteCommand(runner),
)
root.AddCommand(attendee)
return root
}
func newCalendarAttendeeListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list", Short: i18n.T("查询日程参会人"),
Example: " dws calendar attendee list --event <eventId>", Args: cobra.NoArgs, DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
eventID := strings.TrimSpace(firstNonEmptyFlag(cmd, "event", "event-id"))
if eventID == "" {
return apperrors.NewValidation("missing required flag(s): --event")
}
params := map[string]any{"eventId": eventID}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "calendar-id", "calendarId")); v != "" {
params["calendarId"] = v
}
return runCalendarTool(cmd, runner, "get_calendar_participants", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("event", "", i18n.T("日程 eventId (必填)"))
cmd.Flags().String("calendar-id", "", i18n.T("日历 ID (可选, 默认主日历)"))
return cmd
}
func newCalendarAttendeeAddCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "add", Short: i18n.T("添加日程参会人"),
Example: " dws calendar attendee add --event <eventId> --users userId1,userId2", Args: cobra.NoArgs, DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
eventID := strings.TrimSpace(firstNonEmptyFlag(cmd, "event", "event-id"))
users := strings.TrimSpace(firstNonEmptyFlag(cmd, "users", "attendees", "user-ids"))
if eventID == "" {
return apperrors.NewValidation("missing required flag(s): --event")
}
if users == "" {
return apperrors.NewValidation("missing required flag(s): --users")
}
params := map[string]any{"eventId": eventID, "attendeesToAdd": csvToList(users)}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "optional")); v != "" {
params["optional"] = v
}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "calendar-id", "calendarId")); v != "" {
params["calendarId"] = v
}
return runCalendarTool(cmd, runner, "add_calendar_participant", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("event", "", i18n.T("日程 eventId (必填)"))
cmd.Flags().String("users", "", i18n.T("参会人 userId 列表,逗号分隔 (必填)"))
cmd.Flags().String("optional", "", i18n.T("是否可选参会人 (可选)"))
cmd.Flags().String("calendar-id", "", i18n.T("日历 ID (可选)"))
return cmd
}
func newCalendarAttendeeDeleteCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "delete", Short: i18n.T("移除日程参会人"),
Example: " dws calendar attendee delete --event <eventId> --users userId1", Args: cobra.NoArgs, DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
eventID := strings.TrimSpace(firstNonEmptyFlag(cmd, "event", "event-id"))
users := strings.TrimSpace(firstNonEmptyFlag(cmd, "users", "attendees", "user-ids"))
if eventID == "" {
return apperrors.NewValidation("missing required flag(s): --event")
}
if users == "" {
return apperrors.NewValidation("missing required flag(s): --users")
}
params := map[string]any{"eventId": eventID, "attendeesToRemove": csvToList(users)}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "calendar-id", "calendarId")); v != "" {
params["calendarId"] = v
}
return runCalendarTool(cmd, runner, "remove_calendar_participant", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("event", "", i18n.T("日程 eventId (必填)"))
cmd.Flags().String("users", "", i18n.T("参会人 userId 列表,逗号分隔 (必填)"))
cmd.Flags().String("calendar-id", "", i18n.T("日历 ID (可选)"))
return cmd
}
func runCalendarTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error {
inv := executor.NewHelperInvocation(cobracmd.LegacyCommandPath(cmd), "calendar", tool, params)
if commandDryRun(cmd) {
return writeCommandPayload(cmd, inv)
}
result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
func csvToList(s string) []any {
parts := strings.Split(s, ",")
out := make([]any, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+87 -9
View File
@@ -17,6 +17,7 @@ import (
"bytes"
"context"
"encoding/json"
"regexp"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
@@ -121,7 +122,7 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
newChatBotSearchCommand(runner),
)
root.AddCommand(message, group, bot)
root.AddCommand(message, group, bot, newChatFileGroup(runner), newChatMediaGroup())
return root
}
@@ -324,7 +325,7 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("text", "", "消息内容,支持 Markdown (也可作位置参数)")
cmd.Flags().String("title", "", "消息标题 (可选,未指定时从内容截取)")
cmd.Flags().Bool("at-all", false, "@所有人 (仅 --group 群聊生效)")
cmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员 openDingTalkId 列表,逗号分隔 (仅 --group 群聊生效)")
cmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员 openDingtalkId 列表,逗号分隔 (仅 --group 群聊生效);@ 群内机器人时,务必使用 `dws chat group bots --group <openConversationId>` 返回的 openDingtalkId(群级别 ID,与全局搜索结果不同)")
cmd.Flags().String("uuid", "", "幂等 UUID (可选,24h 内相同 uuid 不重复发送)")
cmd.Flags().String("msg-type", "", "富媒体类型: image / file (纯文本/Markdown 留空)")
cmd.Flags().String("media-id", "", "图片 mediaId (msg-type=image 时必填)")
@@ -334,13 +335,14 @@ 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 发送角标)")
cmd.Flags().Bool("ai-tag", true, "标记为「通过AI发送」角标,默认带上(透明标识 AI/CLI 代发);仅当 --ai-tag=false 时不带角标(按本人发送)")
return cmd
}
// attachAITag 仅在用户显式传入 --ai-tag 时,给发送参数加上 clawType,
// 由 IM 服务端据此渲染「通过AI发送」角标 (悟空版渲染「悟空AI发送」)。
// 默认不带:是否标记 AI 发送交由用户自行选择,不强加。
// attachAITag 在 --ai-tag 为真时给发送参数加上 clawType,由 IM 服务端据此
// 渲染「通过AI发送」角标 (悟空版渲染「悟空AI发送」)。--ai-tag 默认 true:
// 经 dws/agent 代发的消息默认带角标以透明标识 AI/CLI 代发,仅当用户显式
// 传 --ai-tag=false 时才不带 (按本人发送)。
func attachAITag(cmd *cobra.Command, params map[string]any) {
if on, _ := cmd.Flags().GetBool("ai-tag"); on {
params["clawType"] = edition.ClawType()
@@ -529,7 +531,7 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
params, tool, err := buildChatMessageSendByBotInvocation(cmd)
params, tool, err := buildChatMessageSendByBotInvocation(cmd, runner)
if err != nil {
return err
}
@@ -555,6 +557,9 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("text", "", "消息内容 Markdown (必填)")
cmd.Flags().String("title", "", "消息标题 (必填)")
cmd.Flags().String("users", "", "接收者 userId 列表,逗号分隔,最多 20 个 (单聊必填)")
cmd.Flags().Bool("at-all", false, "@所有人 (仅 --group 群聊生效)")
cmd.Flags().String("at-user-ids", "", "@指定成员 userId 列表,逗号分隔 (仅 --group 生效);文中的 <@userId> 会被替换为 markdown @userId 语法")
cmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员 openDingtalkId 列表,逗号分隔 (仅 --group 生效);@ 群内机器人时,务必使用 `dws chat group bots --group <openConversationId>` 返回的 openDingtalkId(群级别 ID,与全局搜索结果不同)")
return cmd
}
@@ -641,7 +646,7 @@ func newChatGroupCreateCommand(runner executor.Runner) *cobra.Command {
return cmd
}
func buildChatMessageSendByBotInvocation(cmd *cobra.Command) (map[string]any, string, error) {
func buildChatMessageSendByBotInvocation(cmd *cobra.Command, runner executor.Runner) (map[string]any, string, error) {
guard := cli.NewStdinGuard()
group, err := cmd.Flags().GetString("group")
@@ -689,6 +694,33 @@ func buildChatMessageSendByBotInvocation(cmd *cobra.Command) (map[string]any, st
}
if strings.TrimSpace(group) != "" {
params["openConversationId"] = group
atAll, _ := cmd.Flags().GetBool("at-all")
atUserIDs, _ := cmd.Flags().GetString("at-user-ids")
atOpenIDs, _ := cmd.Flags().GetString("at-open-dingtalk-ids")
if atAll {
params["atAll"] = true
}
userIDList := splitCSVStrings(atUserIDs)
openIDList := splitCSVStrings(atOpenIDs)
// The robot group message API honors two @ dimensions directly:
// atUserIds (staffId) and atOpendingtalkIds (openDingTalkId — note the
// server's lowercase spelling, verified live: the camelCase
// atOpenDingTalkIds is silently ignored). openDingTalkId is the ONLY id
// a bot has, so forwarding it verbatim is what makes bot→bot @ work —
// no userId reverse-lookup needed (and a bot is not in the member
// roster, so a lookup could never resolve it anyway). Each dimension
// also needs the matching `@id` token in the body to render the chip
// (renderAtMentions below rewrites `<@id>` → `@id` and prepends any
// missing mention).
if len(userIDList) > 0 {
params["atUserIds"] = stringSliceToAny(userIDList)
}
if len(openIDList) > 0 {
params["atOpendingtalkIds"] = stringSliceToAny(openIDList)
}
if allIDs := append(append([]string{}, userIDList...), openIDList...); len(allIDs) > 0 {
params["markdown"] = renderAtMentions(params["markdown"].(string), allIDs)
}
return params, "send_robot_group_message", nil
}
@@ -722,6 +754,52 @@ func splitCSVStrings(raw string) []string {
return values
}
// atMentionPlaceholder captures both <@userId> (angle-bracket) and standalone
// @userId placeholders so a caller can write `<@u123>` or `@u123` in the body
// and have it rendered as a DingTalk mention chip.
var atMentionPlaceholder = regexp.MustCompile(`<@([^>\s]+)>`)
// renderAtMentions rewrites mention placeholders in the markdown body so the
// DingTalk client renders a highlight chip. In DingTalk markdown, a mention is
// the literal token `@userId` followed by whitespace; the recipient side then
// looks up the userId in the message's atUserIds array to render it as a chip.
// We (a) rewrite `<@userId>` → `@userId `, and (b) if none of the mentioned
// userIds appear in the body at all, prepend them so the chip still shows up.
func renderAtMentions(body string, userIDs []string) string {
if len(userIDs) == 0 {
return body
}
rewritten := atMentionPlaceholder.ReplaceAllString(body, "@$1 ")
referenced := map[string]bool{}
matches := atMentionPlaceholder.FindAllStringSubmatch(body, -1)
for _, m := range matches {
if len(m) > 1 {
referenced[m[1]] = true
}
}
// Also detect bare @userId occurrences already present in the text.
for _, uid := range userIDs {
if strings.Contains(rewritten, "@"+uid) {
referenced[uid] = true
}
}
var missing []string
for _, uid := range userIDs {
if !referenced[uid] {
missing = append(missing, "@"+uid)
}
}
if len(missing) > 0 {
prefix := strings.Join(missing, " ")
if strings.TrimSpace(rewritten) == "" {
rewritten = prefix
} else {
rewritten = prefix + " " + rewritten
}
}
return rewritten
}
func stringSliceToAny(values []string) []any {
out := make([]any, 0, len(values))
for _, value := range values {
@@ -1017,7 +1095,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 发送角标)")
cmd.Flags().Bool("ai-tag", true, "标记为「通过AI发送」角标,默认带上(透明标识 AI/CLI 代发);仅当 --ai-tag=false 时不带角标(按本人发送)")
return cmd
}
+206
View File
@@ -0,0 +1,206 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"context"
"os"
"path/filepath"
"strings"
"time"
"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"
)
// newChatFileGroup builds `dws chat file upload`. wukong implements it as a
// multi-step upload to the conversation file space: for --file it does
// init_conversation_file_upload (im) -> HTTP PUT -> commit_conversation_file_upload
// (im); for --url it calls upload_conversation_file_by_url (chat). The envelope
// cannot express the local pipeline, so it lives here. Wired into the chat
// handler (see chat.go).
func newChatFileGroup(runner executor.Runner) *cobra.Command {
file := &cobra.Command{
Use: "file",
Short: "会话文件上传",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
}
file.AddCommand(newChatFileUploadCommand(runner))
return file
}
func newChatFileUploadCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "upload",
Short: "上传本地文件或 URL 文件到会话文件空间",
Example: " dws chat file upload --group <openConversationId> --file ./report.pdf\n" +
" dws chat file upload --user <userId> --url https://example.com/a.pdf --file-name a.pdf",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
target, err := chatConversationTargetArgs(cmd)
if err != nil {
return err
}
filePath := strings.TrimSpace(firstNonEmptyFlag(cmd, "file", "file-path"))
fileURL := strings.TrimSpace(firstNonEmptyFlag(cmd, "url"))
if filePath == "" && fileURL == "" {
return apperrors.NewValidation("--file or --url is required")
}
if filePath != "" && fileURL != "" {
return apperrors.NewValidation("--file and --url are mutually exclusive")
}
fileName := strings.TrimSpace(firstNonEmptyFlag(cmd, "file-name"))
md5v := strings.TrimSpace(firstNonEmptyFlag(cmd, "md5"))
uuid := strings.TrimSpace(firstNonEmptyFlag(cmd, "uuid"))
// URL path: server pulls the file (chat server).
if fileURL != "" {
if fileName == "" {
fileName = filepath.Base(fileURL)
}
params := cloneStringAnyMap(target)
params["fileUrl"] = fileURL
params["fileName"] = fileName
if md5v != "" {
params["md5"] = md5v
}
if uuid != "" {
params["uuid"] = uuid
}
inv := executor.NewHelperInvocation(cobracmd.LegacyCommandPath(cmd), "chat", "upload_conversation_file_by_url", params)
if commandDryRun(cmd) {
return writeCommandPayload(cmd, inv)
}
result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
// Local path: init (im) -> HTTP PUT -> commit (im).
fi, err := os.Stat(filePath)
if err != nil {
return apperrors.NewValidation("cannot read file " + filePath + ": " + err.Error())
}
if fi.IsDir() {
return apperrors.NewValidation(filePath + " is a directory, not a file")
}
if fileName == "" {
fileName = filepath.Base(filePath)
}
fileSize := fi.Size()
if md5v == "" {
if md5v, err = fileMD5Hex(filePath); err != nil {
return err
}
}
if commandDryRun(cmd) {
preview := cloneStringAnyMap(target)
preview["fileName"] = fileName
preview["fileSize"] = fileSize
preview["md5"] = md5v
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "im", "init_conversation_file_upload", preview))
}
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
defer cancel()
initParams := cloneStringAnyMap(target)
initParams["fileName"] = fileName
initParams["fileSize"] = fileSize
initParams["md5"] = md5v
initRes, err := runner.Run(ctx, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "im", "init_conversation_file_upload", initParams))
if err != nil {
return err
}
resourceURL := findStringDeep(initRes.Response, "resourceUrl", "resourceURL", "url")
if resourceURL == "" {
resourceURL = findFirstInStringArrayDeep(initRes.Response, "resourceUrls", "resourceURLs")
}
uploadKey := findStringDeep(initRes.Response, "uploadKey", "key")
if resourceURL == "" || uploadKey == "" {
return apperrors.NewAPI("incomplete upload credentials: resourceUrl=" + resourceURL + " uploadKey=" + uploadKey)
}
headers := findHeadersDeep(initRes.Response, "headers", "ossHeaders")
if err := httpPutLocalFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
return err
}
commitParams := cloneStringAnyMap(target)
commitParams["uploadKey"] = uploadKey
commitParams["fileName"] = fileName
commitParams["fileSize"] = fileSize
commitParams["md5"] = md5v
if uuid != "" {
commitParams["uuid"] = uuid
}
commitRes, err := runner.Run(ctx, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "im", "commit_conversation_file_upload", commitParams))
if err != nil {
return err
}
return writeCommandPayload(cmd, commitRes)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("group", "", "群聊 openConversationId(群聊时使用)")
cmd.Flags().String("conversation-id", "", "--group 的别名")
cmd.Flags().String("id", "", "--group 的别名")
cmd.Flags().String("user", "", "单聊对方 userId(单聊时使用)")
cmd.Flags().String("open-dingtalk-id", "", "单聊对方 openDingTalkId(单聊时使用)")
cmd.Flags().String("file", "", "本地文件路径(与 --url 二选一)")
cmd.Flags().String("file-path", "", "--file 的别名")
cmd.Flags().String("url", "", "远程文件 URL(与 --file 二选一,服务端代传)")
cmd.Flags().String("file-name", "", "文件名(可选)")
cmd.Flags().String("md5", "", "文件 MD5(可选,本地不传自动计算)")
cmd.Flags().String("uuid", "", "幂等 UUID(可选)")
return cmd
}
func chatConversationTargetArgs(cmd *cobra.Command) (map[string]any, error) {
group := strings.TrimSpace(firstNonEmptyFlag(cmd, "group", "conversation-id", "id"))
user := strings.TrimSpace(firstNonEmptyFlag(cmd, "user"))
openDingTalkID := strings.TrimSpace(firstNonEmptyFlag(cmd, "open-dingtalk-id"))
if group == "" && user == "" && openDingTalkID == "" {
return nil, apperrors.NewValidation("需指定会话目标:--group(群聊)或 --user / --open-dingtalk-id(单聊)之一")
}
m := map[string]any{}
if group != "" {
m["openConversationId"] = group
}
if user != "" {
m["userId"] = user
}
if openDingTalkID != "" {
m["openDingTalkId"] = openDingTalkID
}
return m, nil
}
func cloneStringAnyMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in)+4)
for k, v := range in {
out[k] = v
}
return out
}
+191
View File
@@ -0,0 +1,191 @@
// 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 (
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
func newChatMediaGroup() *cobra.Command {
media := &cobra.Command{
Use: "media",
Short: "媒体文件管理",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
}
media.AddCommand(newChatMediaUploadCommand())
return media
}
func newChatMediaUploadCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "upload",
Short: "上传图片获取 mediaId(用于 chat message send --msg-type image)",
Example: " dws chat media upload --file ./screenshot.png\n" +
" dws chat media upload --file ./photo.jpg --type image",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
filePath, _ := cmd.Flags().GetString("file")
filePath = strings.TrimSpace(filePath)
if filePath == "" {
return apperrors.NewValidation("--file is required")
}
fi, err := os.Stat(filePath)
if err != nil {
return apperrors.NewValidation("cannot read file: " + err.Error())
}
if fi.IsDir() {
return apperrors.NewValidation(filePath + " is a directory")
}
mediaType, _ := cmd.Flags().GetString("type")
mediaType = strings.TrimSpace(strings.ToLower(mediaType))
if mediaType == "" {
mediaType = "image"
}
ctx, cancel := context.WithTimeout(cmd.Context(), 2*time.Minute)
defer cancel()
token, err := mediaResolveAppToken(ctx)
if err != nil {
return err
}
mediaID, err := mediaUploadFile(ctx, token, filePath, mediaType)
if err != nil {
return err
}
return writeCommandPayload(cmd, map[string]any{
"success": true,
"mediaId": mediaID,
})
},
}
cmd.Flags().String("file", "", "本地文件路径 (必填)")
cmd.Flags().String("type", "image", "媒体类型: image/voice/video/file")
return cmd
}
func mediaResolveAppToken(ctx context.Context) (string, error) {
appKey := os.Getenv("DWS_CLIENT_ID")
appSecret := os.Getenv("DWS_CLIENT_SECRET")
if appKey == "" || appSecret == "" {
return "", apperrors.NewAuth(
"缺少应用凭证。chat media upload 需要 DWS_CLIENT_ID / DWS_CLIENT_SECRET 环境变量。\n" +
"请使用 dws auth login --client-id <APP_KEY> --client-secret <APP_SECRET> 登录。")
}
// media/upload is only served by the legacy oapi endpoint and requires the
// legacy access_token from oapi.dingtalk.com/gettoken (NOT the v1.0
// accessToken). Using the v1.0 token against media/upload returns
// HTTP 404 InvalidAction.NotFound.
url := "https://oapi.dingtalk.com/gettoken?appkey=" + appKey + "&appsecret=" + appSecret
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return "", apperrors.NewAuth("获取访问令牌失败: " + err.Error())
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode >= 400 {
return "", apperrors.NewAuth(fmt.Sprintf("获取访问令牌 HTTP %d: %s", resp.StatusCode, string(raw)))
}
var parsed struct {
AccessToken string `json:"access_token"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return "", apperrors.NewAuth("gettoken 响应解析失败: " + string(raw))
}
if parsed.ErrCode != 0 || parsed.AccessToken == "" {
return "", apperrors.NewAuth(fmt.Sprintf("gettoken errcode=%d errmsg=%s", parsed.ErrCode, parsed.ErrMsg))
}
return parsed.AccessToken, nil
}
func mediaUploadFile(ctx context.Context, token, filePath, mediaType string) (string, error) {
pr, pw := io.Pipe()
writer := multipart.NewWriter(pw)
go func() {
defer pw.Close()
defer writer.Close()
part, err := writer.CreateFormFile("media", filepath.Base(filePath))
if err != nil {
pw.CloseWithError(err)
return
}
f, err := os.Open(filePath)
if err != nil {
pw.CloseWithError(err)
return
}
defer f.Close()
if _, err := io.Copy(part, f); err != nil {
pw.CloseWithError(err)
return
}
}()
// media/upload takes access_token + type as query params, media file as
// multipart form field "media"; returns { errcode, errmsg, media_id }.
url := "https://oapi.dingtalk.com/media/upload?access_token=" + token + "&type=" + mediaType
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, pr)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := (&http.Client{Timeout: 2 * time.Minute}).Do(req)
if err != nil {
return "", apperrors.NewAPI("media upload failed: " + err.Error())
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode >= 400 {
return "", apperrors.NewAPI(fmt.Sprintf("media upload HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))))
}
var parsed struct {
MediaID string `json:"media_id"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return "", apperrors.NewAPI("media upload 响应解析失败: " + string(body))
}
if parsed.ErrCode != 0 || strings.TrimSpace(parsed.MediaID) == "" {
return "", apperrors.NewAPI(fmt.Sprintf("media upload errcode=%d errmsg=%s body=%s", parsed.ErrCode, parsed.ErrMsg, string(body)))
}
return strings.TrimSpace(parsed.MediaID), nil
}
+148 -5
View File
@@ -354,8 +354,8 @@ func TestChatMessageAITagControlsClawType(t *testing.T) {
},
}
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) {
// Default: no --ai-tag → ai-tag defaults to true → must attach clawType.
t.Run(tc.name+"/default-has-tag", func(t *testing.T) {
runner := &captureRunner{}
cmd := tc.make(runner)
var out bytes.Buffer
@@ -365,11 +365,30 @@ func TestChatMessageAITagControlsClawType(t *testing.T) {
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)
got, ok := runner.last.Params["clawType"]
if !ok {
t.Fatalf("default send must attach clawType (ai-tag defaults true); got %#v", runner.last.Params)
}
if got != edition.DefaultOSSClawType {
t.Fatalf("clawType = %#v, want %q", got, edition.DefaultOSSClawType)
}
})
// Opt-in: --ai-tag → attach the edition claw identity.
// Opt-out: --ai-tag=false → omit clawType entirely (no badge).
t.Run(tc.name+"/ai-tag-false", 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=false"))
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("--ai-tag=false must omit clawType, got %#v", v)
}
})
// Opt-in (explicit): --ai-tag → attach the edition claw identity.
t.Run(tc.name+"/with-ai-tag", func(t *testing.T) {
runner := &captureRunner{}
cmd := tc.make(runner)
@@ -408,6 +427,82 @@ func TestChatMessageSendByBotOmitsClawType(t *testing.T) {
}
}
// send-by-bot --at-user-ids: userIds go into atUserIds AND the body's
// `<@userId>` placeholder is rewritten to the `@userId ` chip form. Regression
// guard for 勤泽 2026-07-04 report where the body still shipped raw `<@id>`.
func TestChatMessageSendByBotAtUserIDsRendersChip(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", "<@u1> ping",
"--at-user-ids", "u1",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
got, ok := runner.last.Params["markdown"].(string)
if !ok {
t.Fatalf("markdown missing or not string; got %#v", runner.last.Params["markdown"])
}
if !strings.Contains(got, "@u1 ") {
t.Fatalf("markdown should contain rewritten @u1 chip, got %q", got)
}
if strings.Contains(got, "<@u1>") {
t.Fatalf("markdown should NOT contain raw placeholder <@u1>, got %q", got)
}
users, ok := runner.last.Params["atUserIds"].([]any)
if !ok || len(users) != 1 || users[0] != "u1" {
t.Fatalf("atUserIds = %#v, want [u1]", runner.last.Params["atUserIds"])
}
}
// send-by-bot --at-open-dingtalk-ids: the openDingTalkId is forwarded verbatim
// as atOpendingtalkIds (the server's lowercase spelling — the camelCase
// atOpenDingTalkIds is silently ignored), and the `<@openId>` placeholder is
// rewritten to `@openId` in the body so the chip renders. This is what makes
// @-ing a bot (whose only id is an openDingTalkId) actually deliver.
func TestChatMessageSendByBotAtOpenIDsRendersChip(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", "<@op1> ping",
"--at-open-dingtalk-ids", "op1",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
got, ok := runner.last.Params["markdown"].(string)
if !ok {
t.Fatalf("markdown missing or not string; got %#v", runner.last.Params["markdown"])
}
if !strings.Contains(got, "@op1 ") {
t.Fatalf("markdown should contain rewritten @op1 chip, got %q", got)
}
if strings.Contains(got, "<@op1>") {
t.Fatalf("markdown should NOT contain raw placeholder <@op1>, got %q", got)
}
opens, ok := runner.last.Params["atOpendingtalkIds"].([]any)
if !ok || len(opens) != 1 || opens[0] != "op1" {
t.Fatalf("atOpendingtalkIds = %#v, want [op1]", runner.last.Params["atOpendingtalkIds"])
}
// The camelCase field must NOT be set (the server ignores it).
if _, exists := runner.last.Params["atOpenDingTalkIds"]; exists {
t.Fatalf("must not send camelCase atOpenDingTalkIds; params=%#v", runner.last.Params)
}
}
func equalAny(a, b any) bool {
switch av := a.(type) {
case []any:
@@ -485,3 +580,51 @@ func TestChatMessageSendByBotRoutesToBotProduct(t *testing.T) {
})
}
}
func TestRenderAtMentions(t *testing.T) {
cases := []struct {
name string
body string
userIDs []string
want string
}{
{
name: "empty userIDs is no-op",
body: "hello",
userIDs: nil,
want: "hello",
},
{
name: "angle placeholder rewritten",
body: "<@u001> 请查收",
userIDs: []string{"u001"},
want: "@u001 请查收",
},
{
name: "missing mention auto-prepended",
body: "请查收",
userIDs: []string{"u001", "u002"},
want: "@u001 @u002 请查收",
},
{
name: "bare @userId already present, no prefix",
body: "hi @u001 please",
userIDs: []string{"u001"},
want: "hi @u001 please",
},
{
name: "mixed: one placeholder + one missing",
body: "<@u001> ping",
userIDs: []string{"u001", "u002"},
want: "@u002 @u001 ping",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := renderAtMentions(tc.body, tc.userIDs)
if got != tc.want {
t.Fatalf("renderAtMentions(%q, %v) = %q, want %q", tc.body, tc.userIDs, got, tc.want)
}
})
}
}
+11 -5
View File
@@ -168,15 +168,21 @@ func (c *aiCardClient) callRaw(ctx context.Context, method, path string, payload
if err != nil {
return "", err
}
body, err := json.Marshal(payload)
var bodyReader io.Reader
if payload != nil {
body, merr := json.Marshal(payload)
if merr != nil {
return "", merr
}
bodyReader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, dingtalkCardAPIBase+path, bodyReader)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, method, dingtalkCardAPIBase+path, bytes.NewReader(body))
if err != nil {
return "", err
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-acs-dingtalk-access-token", token)
resp, err := c.httpClient.Do(req)
if err != nil {
+1 -1
View File
@@ -103,7 +103,7 @@ func (f *codexAppServerForwarder) forwardStream(ctx context.Context, convID, tex
}
func (f *codexAppServerForwarder) forwardAppServer(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
ctx, cancel := context.WithTimeout(ctx, f.timeout)
ctx, cancel := applyTimeout(ctx, f.timeout)
defer cancel()
var state *codexThreadState
+511 -48
View File
@@ -26,8 +26,11 @@ import (
"syscall"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/spf13/cobra"
)
@@ -58,11 +61,21 @@ const (
// supervisor pid plus enough context for `status` to report without re-deriving
// it (start time for uptime, log path, the dir key it was filed under).
type daemonState struct {
Pid int `json:"pid"`
StartUnix int64 `json:"startUnix"`
LogPath string `json:"logPath"`
DirKey string `json:"dirKey"`
ClientID string `json:"clientId,omitempty"`
Pid int `json:"pid"`
StartUnix int64 `json:"startUnix"`
LogPath string `json:"logPath"`
DirKey string `json:"dirKey"`
ClientID string `json:"clientId,omitempty"`
UnifiedAppID string `json:"unifiedAppId,omitempty"`
Channel string `json:"channel,omitempty"`
NotifyStaffID string `json:"notifyStaffId,omitempty"`
// Profile records the --profile selector the connector was started with,
// so `restart` re-fetches credentials against the same org instead of the
// default profile (which may not know the unifiedAppId at all).
Profile string `json:"profile,omitempty"`
// AlwaysOn controls whether the supervisor auto-restarts the worker on
// crash. Without it the supervisor exits after the first worker exit.
AlwaysOn bool `json:"alwaysOn,omitempty"`
}
// connectDaemonDirOverride lets tests redirect the per-client daemon directory
@@ -97,38 +110,42 @@ func connectDaemonDir(dirKey string) (string, error) {
return dir, nil
}
func daemonPidPath(dir string) string { return filepath.Join(dir, "daemon.pid") }
func daemonLogPath(dir string) string { return filepath.Join(dir, "daemon.log") }
func daemonPidPath(dir string) string { return filepath.Join(dir, "daemon.pid") }
func daemonStatePath(dir string) string { return filepath.Join(dir, "daemon-state.json") }
func daemonLogPath(dir string) string { return filepath.Join(dir, "daemon.log") }
// writeDaemonState atomically persists the daemon pid file (write temp + rename)
// so a reader never sees a half-written file.
// writeDaemonState atomically persists the daemon state to daemon-state.json
// (persistent, survives supervisor exit) so restart/list can recover config.
func writeDaemonState(dir string, st daemonState) error {
data, err := json.MarshalIndent(st, "", " ")
if err != nil {
return err
}
tmp := daemonPidPath(dir) + ".tmp"
tmp := daemonStatePath(dir) + ".tmp"
if err := os.WriteFile(tmp, data, config.FilePerm); err != nil {
return err
}
return os.Rename(tmp, daemonPidPath(dir))
return os.Rename(tmp, daemonStatePath(dir))
}
// readDaemonState loads the daemon pid file. A missing file yields (nil, nil) so
// callers can treat "not running" distinctly from a real I/O error.
// readDaemonState loads the daemon state. Reads daemon-state.json (persistent)
// first, falls back to daemon.pid for backward compat with old connectors.
func readDaemonState(dir string) (*daemonState, error) {
data, err := os.ReadFile(daemonPidPath(dir))
if err != nil {
if os.IsNotExist(err) {
return nil, nil
for _, p := range []string{daemonStatePath(dir), daemonPidPath(dir)} {
data, err := os.ReadFile(p)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, err
}
return nil, err
var st daemonState
if err := json.Unmarshal(data, &st); err != nil {
return nil, fmt.Errorf("daemon state file %s is corrupt: %w", p, err)
}
return &st, nil
}
var st daemonState
if err := json.Unmarshal(data, &st); err != nil {
return nil, fmt.Errorf("daemon pid file %s is corrupt: %w", daemonPidPath(dir), err)
}
return &st, nil
return nil, nil
}
// backoffDelay computes the restart delay for the Nth consecutive worker
@@ -190,7 +207,7 @@ func buildWorkerArgs(args []string) []string {
// startDaemon implements `connect --daemon`: it re-execs dws in supervisor mode
// detached from the terminal, writes nothing itself to the worker log (the
// supervisor does), prints the pid + log path, and returns so the parent exits.
func startDaemon(cmd *cobra.Command, dirKey, clientID string) error {
func startDaemon(cmd *cobra.Command, dirKey, clientID, unifiedAppID, channel, notifyStaffID, profile string, alwaysOn bool) error {
if !daemonDetachSupported {
return apperrors.NewValidation("--daemon is not supported on this OS; run the foreground connector under a service manager instead")
}
@@ -228,7 +245,14 @@ func startDaemon(cmd *cobra.Command, dirKey, clientID string) error {
child.Env = append(os.Environ(),
"DWS_CONNECT_DAEMON_DIRKEY="+dirKey,
"DWS_CONNECT_DAEMON_CLIENTID="+clientID,
"DWS_CONNECT_DAEMON_UNIFIEDAPPID="+unifiedAppID,
"DWS_CONNECT_DAEMON_CHANNEL="+channel,
"DWS_CONNECT_DAEMON_NOTIFY_STAFF_ID="+notifyStaffID,
"DWS_CONNECT_DAEMON_PROFILE="+profile,
)
if alwaysOn {
child.Env = append(child.Env, "DWS_CONNECT_DAEMON_ALWAYSON=true")
}
if connectDaemonDirOverride != "" {
child.Env = append(child.Env, "DWS_CONNECT_DAEMON_DIR="+connectDaemonDirOverride)
}
@@ -248,8 +272,8 @@ func startDaemon(cmd *cobra.Command, dirKey, clientID string) error {
func writeConnectDaemonStarted(w io.Writer, pid int, logPath, clientID, dirKey string) {
fmt.Fprintf(w, "connect daemon started (pid %d)\n", pid)
fmt.Fprintf(w, " logs: %s\n", logPath)
fmt.Fprintf(w, " status: dws devapp robot connect status%s\n", statusHintArgs(clientID, dirKey))
fmt.Fprintf(w, " stop: dws devapp robot connect stop%s\n", statusHintArgs(clientID, dirKey))
fmt.Fprintf(w, " status: dws dev connect status%s\n", statusHintArgs(clientID, dirKey))
fmt.Fprintf(w, " stop: dws dev connect stop%s\n", statusHintArgs(clientID, dirKey))
fmt.Fprint(w, connectLocalDebugNotice())
}
@@ -291,16 +315,26 @@ func runSupervisor(cmd *cobra.Command) error {
connectDaemonDirOverride = v
}
clientID := strings.TrimSpace(os.Getenv("DWS_CONNECT_DAEMON_CLIENTID"))
unifiedAppID := strings.TrimSpace(os.Getenv("DWS_CONNECT_DAEMON_UNIFIEDAPPID"))
channel := strings.TrimSpace(os.Getenv("DWS_CONNECT_DAEMON_CHANNEL"))
notifyStaffID := strings.TrimSpace(os.Getenv("DWS_CONNECT_DAEMON_NOTIFY_STAFF_ID"))
profile := strings.TrimSpace(os.Getenv("DWS_CONNECT_DAEMON_PROFILE"))
alwaysOn := strings.TrimSpace(os.Getenv("DWS_CONNECT_DAEMON_ALWAYSON")) == "true"
dir, err := connectDaemonDir(dirKey)
if err != nil {
return apperrors.NewInternal("create daemon dir: " + err.Error())
}
st := daemonState{
Pid: os.Getpid(),
StartUnix: time.Now().Unix(),
LogPath: daemonLogPath(dir),
DirKey: dirKey,
ClientID: clientID,
Pid: os.Getpid(),
StartUnix: time.Now().Unix(),
LogPath: daemonLogPath(dir),
DirKey: dirKey,
ClientID: clientID,
UnifiedAppID: unifiedAppID,
Channel: channel,
NotifyStaffID: notifyStaffID,
Profile: profile,
AlwaysOn: alwaysOn,
}
if err := writeDaemonState(dir, st); err != nil {
return apperrors.NewInternal("write daemon pid file: " + err.Error())
@@ -331,16 +365,19 @@ func runSupervisor(cmd *cobra.Command) error {
}
failures := 0
daemonNotifyStateChange(notifyStaffID, channel, clientID, "started", "")
for {
if delay := backoffDelay(failures, daemonBackoffBase, daemonBackoffCap); delay > 0 {
fmt.Fprintf(out, "[daemon] restarting worker in %s (consecutive failures: %d)\n", delay, failures)
select {
case <-ctx.Done():
daemonNotifyStateChange(notifyStaffID, channel, clientID, "stopped", "")
return nil
case <-time.After(delay):
}
}
if ctx.Err() != nil {
daemonNotifyStateChange(notifyStaffID, channel, clientID, "stopped", "")
return nil
}
@@ -353,6 +390,7 @@ func runSupervisor(cmd *cobra.Command) error {
fmt.Fprintf(out, "[daemon] failed to start worker: %v\n", err)
failures++
if failures >= daemonMaxFastFailures {
daemonNotifyStateChange(notifyStaffID, channel, clientID, "gave_up", fmt.Sprintf("worker 启动失败 %d 次", failures))
return apperrors.NewInternal("daemon worker failed to start too many times; giving up")
}
continue
@@ -365,6 +403,13 @@ func runSupervisor(cmd *cobra.Command) error {
if ctx.Err() != nil {
// We were asked to stop; the worker has been (or is being) signalled.
fmt.Fprintln(out, "[daemon] stop requested, worker shut down; exiting supervisor")
daemonNotifyStateChange(notifyStaffID, channel, clientID, "stopped", "")
return nil
}
if !alwaysOn {
fmt.Fprintln(out, "[daemon] worker exited; not restarting (--alwayson not set)")
daemonNotifyStateChange(notifyStaffID, channel, clientID, "stopped", "worker 退出,未启用 --alwayson")
return nil
}
@@ -375,8 +420,10 @@ func runSupervisor(cmd *cobra.Command) error {
}
fmt.Fprintf(out, "[daemon] worker exited after %s (err=%v); consecutive failures: %d\n", ran.Round(time.Second), waitErr, failures)
if failures >= daemonMaxFastFailures {
daemonNotifyStateChange(notifyStaffID, channel, clientID, "gave_up", fmt.Sprintf("连续崩溃 %d 次", failures))
return apperrors.NewInternal(fmt.Sprintf("daemon worker crashed %d times in a row; giving up (check %s)", failures, daemonLogPath(dir)))
}
daemonNotifyStateChange(notifyStaffID, channel, clientID, "crashed", fmt.Sprintf("worker 退出 (%s),正在重启", ran.Round(time.Second)))
}
}
@@ -401,8 +448,12 @@ func superviseWait(ctx context.Context, worker *exec.Cmd) error {
}
}
// daemonStatus reports the state of the connector daemon to w.
func daemonStatus(w io.Writer, dirKey string) error {
// daemonStatus reports connector health to w. It combines two independent
// signals: the daemon supervisor pid file (is a supervisor alive) and the
// connector heartbeat (is the connection live and receiving — see
// connect_health.go). jsonOut emits the machine-readable health report an
// external supervisor (launchd/systemd/pm2/cron) consumes to decide restarts.
func daemonStatus(w io.Writer, dirKey string, jsonOut bool) error {
dir, err := connectDaemonDir(dirKey)
if err != nil {
return apperrors.NewInternal("resolve daemon dir: " + err.Error())
@@ -411,23 +462,60 @@ func daemonStatus(w io.Writer, dirKey string) error {
if err != nil {
return apperrors.NewInternal(err.Error())
}
if st == nil {
fmt.Fprintf(w, "connect daemon: not running (no pid file under %s)\n", dir)
supervised := st != nil && st.Pid > 0 && processAlive(st.Pid)
hb, err := readConnectHeartbeat(dir)
if err != nil {
return apperrors.NewInternal("read connector heartbeat: " + err.Error())
}
report := deriveConnectHealth(hb, supervised, time.Now())
if jsonOut {
data, merr := json.MarshalIndent(report, "", " ")
if merr != nil {
return apperrors.NewInternal(merr.Error())
}
fmt.Fprintln(w, string(data))
return nil
}
if st.Pid <= 0 || !processAlive(st.Pid) {
fmt.Fprintf(w, "connect daemon: not running (stale pid file for pid %d at %s)\n", st.Pid, daemonPidPath(dir))
return nil
var lines []string
lines = append(lines, fmt.Sprintf("%s %s", tui.Key("state"), colorConnectState(report.State)))
if report.Detail != "" {
lines = append(lines, fmt.Sprintf("%s %s", tui.Key("detail"), report.Detail))
}
uptime := time.Since(time.Unix(st.StartUnix, 0)).Round(time.Second)
fmt.Fprintf(w, "connect daemon: running\n")
fmt.Fprintf(w, " pid: %d\n", st.Pid)
fmt.Fprintf(w, " uptime: %s\n", uptime)
fmt.Fprintf(w, " logs: %s\n", st.LogPath)
if st.ClientID != "" {
fmt.Fprintf(w, " client: %s\n", st.ClientID)
if report.Pid > 0 {
lines = append(lines, fmt.Sprintf("%s %s", tui.Key("pid"), tui.White(fmt.Sprintf("%d", report.Pid))))
}
return nil
if report.Channel != "" {
lines = append(lines, fmt.Sprintf("%s %s", tui.Key("channel"), tui.White(report.Channel)))
}
if report.ClientID != "" {
lines = append(lines, fmt.Sprintf("%s %s", tui.Key("client"), tui.White(report.ClientID)))
}
if report.UptimeSec > 0 {
lines = append(lines, fmt.Sprintf("%s %s", tui.Key("uptime"), tui.White((time.Duration(report.UptimeSec)*time.Second).Round(time.Second).String())))
}
lines = append(lines, fmt.Sprintf("%s %s", tui.Key("super"), supervisedLabel(supervised)))
if hb != nil {
if report.LastPushAgoSec > 0 {
lines = append(lines, fmt.Sprintf("%s %s ago", tui.Key("recv"), tui.White((time.Duration(report.LastPushAgoSec)*time.Second).Round(time.Second).String())))
} else {
lines = append(lines, fmt.Sprintf("%s %s", tui.Key("recv"), tui.Dim("(none since start)")))
}
if report.LastError != "" {
lines = append(lines, fmt.Sprintf("%s %s", tui.Key("error"), tui.Danger(report.LastError)))
}
lines = append(lines, fmt.Sprintf("%s %s", tui.Key("logs"), tui.Dim(daemonLogPath(dir))))
}
return tui.Panel(w, tui.Bold("connect status"), lines)
}
func supervisedLabel(supervised bool) string {
if supervised {
return "running (--daemon)"
}
return "none (foreground or external)"
}
// daemonStop gracefully stops the connector daemon: SIGTERM the supervisor (it
@@ -448,6 +536,28 @@ func daemonStop(w io.Writer, dirKey string) error {
}
if !processAlive(st.Pid) {
_ = os.Remove(daemonPidPath(dir))
// The supervisor is dead, but its worker may still be alive (e.g. the
// supervisor was kill -9'd). Check the heartbeat for the worker pid and
// stop it so we don't leave an orphan.
if hb, _ := readConnectHeartbeat(dir); hb != nil && hb.Pid > 0 && processAlive(hb.Pid) {
fmt.Fprintf(w, "connect daemon: supervisor (pid %d) was dead, stopping orphan worker (pid %d)...\n", st.Pid, hb.Pid)
if proc, perr := os.FindProcess(hb.Pid); perr == nil {
_ = proc.Signal(syscall.SIGTERM)
deadline := time.Now().Add(daemonStopTimeout)
for time.Now().Before(deadline) {
if !processAlive(hb.Pid) {
break
}
time.Sleep(200 * time.Millisecond)
}
if processAlive(hb.Pid) {
_ = proc.Signal(syscall.SIGKILL)
time.Sleep(200 * time.Millisecond)
}
}
fmt.Fprintf(w, "connect daemon: orphan worker stopped (pid %d)\n", hb.Pid)
return nil
}
fmt.Fprintf(w, "connect daemon: was not running (cleaned up stale pid file for pid %d)\n", st.Pid)
return nil
}
@@ -483,7 +593,7 @@ func daemonStop(w io.Writer, dirKey string) error {
func newDevAppRobotConnectStatusCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "status",
Short: "查看后台连接器守护进程状态(pid、运行时长、日志路径)",
Short: "查看连接器健康状态(healthy/degraded/down,pid、收发活动、日志路径;--json 供外部托管消费)",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, _ []string) error {
@@ -491,12 +601,14 @@ func newDevAppRobotConnectStatusCommand() *cobra.Command {
if err != nil {
return err
}
return daemonStatus(cmd.OutOrStdout(), dirKey)
jsonOut, _ := cmd.Flags().GetBool("json")
return daemonStatus(cmd.OutOrStdout(), dirKey, jsonOut)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("robot-client-id", "", "机器人 clientId(定位守护进程)")
cmd.Flags().String("unified-app-id", "", "统一应用 ID(当未用 clientId 起守护进程时定位)")
cmd.Flags().Bool("json", false, "以 JSON 输出健康报告(供 launchd/systemd/pm2/cron 判断是否重启)")
return cmd
}
@@ -523,6 +635,209 @@ func newDevAppRobotConnectStopCommand() *cobra.Command {
return cmd
}
// newDevAppRobotConnectRestartCommand implements `dws devapp robot connect
// restart`: stop the running daemon (if any) then re-launch it using the
// persisted unifiedAppId so credentials are freshly fetched from the dev
// platform — no secrets stored on disk.
func newDevAppRobotConnectRestartCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "restart",
Short: "重启连接器守护进程(通过持久化的 unifiedAppId 重新拉取密钥,无需本地存密钥)",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, _ []string) error {
dirKey, err := connectDaemonDirKeyFromFlags(cmd)
if err != nil {
return err
}
dir, err := connectDaemonDir(dirKey)
if err != nil {
return apperrors.NewInternal("resolve daemon dir: " + err.Error())
}
st, err := readDaemonState(dir)
if err != nil {
return apperrors.NewInternal(err.Error())
}
if st == nil {
return apperrors.NewValidation("未找到连接器记录(没有 daemon.pid);请用 `dws dev connect --daemon` 首次启动")
}
unifiedAppID := st.UnifiedAppID
if unifiedAppID == "" {
return apperrors.NewValidation("该连接器未持久化 unifiedAppId(可能是用 --robot-client-id/--robot-client-secret 直接启动的,无法安全重启:clientSecret 不落盘);请停掉后用 `dws dev connect --daemon --unified-app-id <uappid>` 重新启动,之后 restart 就能自动从 credentials get 拉密钥、命令行不出现 secret")
}
// Stop the running daemon first (ignore "not running" — that's fine).
fmt.Fprintln(cmd.OutOrStdout(), "stopping existing daemon...")
if err := daemonStop(cmd.OutOrStdout(), dirKey); err != nil {
fmt.Fprintf(cmd.OutOrStderr(), "warning: stop returned %v (continuing with restart)\n", err)
}
// Re-exec dws dev connect --daemon with the stored flags. An explicit
// --profile on this invocation overrides the persisted one.
exe, err := os.Executable()
if err != nil {
return apperrors.NewInternal("resolve executable: " + err.Error())
}
profile := st.Profile
if v, _ := cmd.Root().PersistentFlags().GetString("profile"); strings.TrimSpace(v) != "" {
profile = strings.TrimSpace(v)
}
args := []string{"dev", "connect", "--daemon", "--unified-app-id", unifiedAppID}
if st.Channel != "" {
args = append(args, "--channel", st.Channel)
}
if st.NotifyStaffID != "" {
args = append(args, "--notify-staff-id", st.NotifyStaffID)
}
if profile != "" {
args = append(args, "--profile", profile)
}
if st.AlwaysOn {
args = append(args, "--alwayson")
}
fmt.Fprintf(cmd.OutOrStdout(), "restarting connector: dws %s\n", strings.Join(args, " "))
// Run synchronously: `--daemon` itself detaches the supervisor and
// returns quickly, so waiting here costs nothing and lets a failed
// relaunch (e.g. credential fetch error) surface as a non-zero exit
// instead of a silent success.
restartCmd := exec.Command(exe, args...)
restartCmd.Stdout = cmd.OutOrStdout()
restartCmd.Stderr = cmd.OutOrStderr()
restartCmd.Stdin = nil
if err := restartCmd.Run(); err != nil {
return apperrors.NewInternal(fmt.Sprintf("重启失败(旧守护进程已停止,连接器记录已清除);恢复请手动执行: dws %s", strings.Join(args, " ")))
}
return nil
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("robot-client-id", "", "机器人 clientId(定位守护进程)")
cmd.Flags().String("unified-app-id", "", "统一应用 ID(当未用 clientId 起守护进程时定位)")
return cmd
}
// newDevAppRobotConnectListCommand implements `dws dev connect list`: enumerate
// every connector on this machine and its health, so a developer running
// several robots sees at a glance which are alive/degraded/down without
// querying each clientId. `--json` emits the array for scripts.
func newDevAppRobotConnectListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "列出本机所有连接器及健康状态(healthy/degraded/down);--json 供脚本消费",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, _ []string) error {
reports, err := listConnectors(time.Now())
if err != nil {
return apperrors.NewInternal(err.Error())
}
resolveAppNames(cmd, runner, reports)
w := cmd.OutOrStdout()
if jsonOut, _ := cmd.Flags().GetBool("json"); jsonOut {
data, merr := json.MarshalIndent(reports, "", " ")
if merr != nil {
return apperrors.NewInternal(merr.Error())
}
fmt.Fprintln(w, string(data))
return nil
}
if len(reports) == 0 {
fmt.Fprintln(w, "no connectors found")
return nil
}
return writeConnectListTable(w, reports)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().Bool("json", false, "以 JSON 数组输出(供脚本消费)")
return cmd
}
// resolveAppNames calls list_dev_app once to build a unifiedAppId→name map,
// then fills in AppName on each report. Failures are silent (name stays empty)
// so the list still works offline or when the API is unreachable.
func resolveAppNames(cmd *cobra.Command, runner executor.Runner, reports []connectHealthReport) {
need := false
for i := range reports {
if reports[i].UnifiedAppID != "" {
need = true
break
}
}
if !need {
return
}
nameMap, err := devAppNameMap(cmd, runner)
if err != nil || nameMap == nil {
return
}
for i := range reports {
if reports[i].UnifiedAppID == "" {
continue
}
if name, ok := nameMap[reports[i].UnifiedAppID]; ok && name != "" {
reports[i].AppName = name
}
}
}
// devAppNameMap calls list_dev_app with pagination to build a full
// unifiedAppId→appName map. It is best-effort: any error returns an empty map.
func devAppNameMap(cmd *cobra.Command, runner executor.Runner) (map[string]string, error) {
out := make(map[string]string)
cursor := ""
for page := 0; page < 20; page++ {
params := map[string]any{"pageSize": 100}
if cursor != "" {
params["cursor"] = cursor
}
inv := executor.NewHelperInvocation(cobracmd.LegacyCommandPath(cmd), devAppProduct, devAppListTool, params)
res, err := runner.Run(cmd.Context(), inv)
if err != nil {
return out, err
}
payload := devAppConnectUnwrap(res.Response)
items := devAppConnectList(payload)
for _, item := range items {
uid := devAppConnectFirst(item, "unifiedAppId", "id")
name := devAppConnectFirst(item, "name", "appName")
if uid != "" && name != "" {
out[uid] = name
}
}
hasMore := false
if v, ok := payload["hasMore"].(bool); ok {
hasMore = v
}
if !hasMore {
break
}
cursor = devAppConnectFirst(payload, "nextCursor", "cursor")
if cursor == "" {
break
}
}
return out, nil
}
// devAppConnectList extracts the array of app items from a list_dev_app payload,
// tolerating various wrapper shapes.
func devAppConnectList(payload map[string]any) []map[string]any {
if payload == nil {
return nil
}
for _, key := range []string{"items", "list", "data"} {
if arr, ok := payload[key].([]any); ok {
out := make([]map[string]any, 0, len(arr))
for _, e := range arr {
if m, ok := e.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
}
return nil
}
// connectDaemonDirKeyFromFlags resolves the daemon directory key from the
// status/stop flags, requiring at least one identifier.
func connectDaemonDirKeyFromFlags(cmd *cobra.Command) (string, error) {
@@ -534,3 +849,151 @@ func connectDaemonDirKeyFromFlags(cmd *cobra.Command) (string, error) {
}
return dirKey, nil
}
func colorConnectState(state string) string {
switch state {
case healthHealthy:
return tui.Success(state)
case healthDegraded:
return tui.Warning(state)
case healthDown, healthNotRunning:
return tui.Danger(state)
default:
return tui.Cyan(state)
}
}
func writeConnectListTable(w io.Writer, reports []connectHealthReport) error {
type col struct {
header string
width int
}
cols := []col{
{"STATE", 11},
{"APP NAME", 8},
{"CLIENT", 8},
{"PID", 6},
{"CHANNEL", 7},
{"UPTIME", 6},
}
// compute column widths from data
for _, r := range reports {
if w := tui.PlainRuneWidth(r.AppName); w > cols[1].width {
cols[1].width = w
}
if w := tui.PlainRuneWidth(r.ClientID); w > cols[2].width {
cols[2].width = w
}
if w := tui.PlainRuneWidth(r.Channel); w > cols[4].width {
cols[4].width = w
}
}
for i := range cols {
if cols[i].width > tui.MaxTableColumnWidth {
cols[i].width = tui.MaxTableColumnWidth
}
}
writeBorder := func(left, mid, right string, colorFn func(string) string) {
fmt.Fprint(w, colorFn(left))
for i, c := range cols {
if i > 0 {
fmt.Fprint(w, colorFn(mid))
}
fmt.Fprint(w, colorFn(strings.Repeat("─", c.width+2)))
}
fmt.Fprintln(w, colorFn(right))
}
writeRowCells := func(cells []string) {
fmt.Fprint(w, tui.Gray("│"))
for i, c := range cols {
cell := ""
if i < len(cells) {
cell = cells[i]
}
fmt.Fprintf(w, " %s ", tui.PadRightANSI(cell, c.width))
fmt.Fprint(w, tui.Gray("│"))
}
fmt.Fprintln(w)
}
// header
writeBorder("╭", "┬", "╮", tui.Blue)
headers := make([]string, len(cols))
for i, c := range cols {
headers[i] = tui.Brand(c.header)
}
writeRowCells(headers)
writeBorder("├", "┼", "┤", tui.Gray)
// rows
for _, r := range reports {
uptime := tui.Dim("-")
if r.UptimeSec > 0 {
uptime = tui.White((time.Duration(r.UptimeSec) * time.Second).Round(time.Second).String())
}
pid := tui.Dim("-")
if r.Pid > 0 {
pid = tui.White(fmt.Sprintf("%d", r.Pid))
}
channel := tui.Dim("-")
if r.Channel != "" {
channel = tui.White(r.Channel)
}
appName := tui.Dim("-")
if r.AppName != "" {
appName = tui.White(r.AppName)
}
writeRowCells([]string{
colorConnectState(r.State),
appName,
tui.White(r.ClientID),
pid,
channel,
uptime,
})
}
writeBorder("╰", "┴", "╯", tui.Blue)
return nil
}
// daemonNotifyStateChange sends a DingTalk message to the configured staff
// when the connector state changes. No-op when notifyStaffID is empty. It
// execs `dws chat message send` as a subprocess (fire-and-forget) so the
// supervisor is never blocked by notification delivery.
func daemonNotifyStateChange(notifyStaffID, channel, clientID, event, detail string) {
if notifyStaffID == "" {
return
}
var msg string
switch event {
case "started":
msg = fmt.Sprintf("机器人已启动 ✅\n渠道: %s\nclientId: %s", channel, clientID)
case "stopped":
msg = fmt.Sprintf("机器人已停止 ⏹️\n渠道: %s\nclientId: %s", channel, clientID)
case "crashed":
msg = fmt.Sprintf("机器人已崩溃 ⚠️\n渠道: %s\n%s\n正在自动重启...", channel, detail)
case "gave_up":
msg = fmt.Sprintf("机器人重启失败 ❌\n渠道: %s\n%s\n请检查日志后手动重启", channel, detail)
default:
return
}
exe, err := os.Executable()
if err != nil {
return
}
go func() {
cmd := exec.Command(exe, "chat", "message", "send",
"--staff-id", notifyStaffID,
"--text", msg,
"--ai-tag=false",
"--yes",
"--format", "json",
)
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
_ = cmd.Run()
}()
}
+61 -15
View File
@@ -15,6 +15,7 @@ package helpers
import (
"bytes"
"encoding/json"
"os"
"strings"
"testing"
@@ -127,7 +128,7 @@ func TestDaemonStateRoundTrip(t *testing.T) {
if st, err := readDaemonState(dir); err != nil || st != nil {
t.Fatalf("expected (nil,nil) for missing pid file, got (%v,%v)", st, err)
}
want := daemonState{Pid: 4242, StartUnix: time.Now().Unix(), LogPath: "/x/y.log", DirKey: "roundtrip", ClientID: "cid"}
want := daemonState{Pid: 4242, StartUnix: time.Now().Unix(), LogPath: "/x/y.log", DirKey: "roundtrip", ClientID: "cid", Profile: "ding123", AlwaysOn: true}
if err := writeDaemonState(dir, want); err != nil {
t.Fatalf("writeDaemonState: %v", err)
}
@@ -138,6 +139,12 @@ func TestDaemonStateRoundTrip(t *testing.T) {
if got.Pid != want.Pid || got.DirKey != want.DirKey || got.ClientID != want.ClientID || got.LogPath != want.LogPath {
t.Errorf("round trip mismatch: got %+v want %+v", *got, want)
}
if got.Profile != want.Profile {
t.Errorf("Profile round trip: got %q want %q", got.Profile, want.Profile)
}
if got.AlwaysOn != want.AlwaysOn {
t.Errorf("AlwaysOn round trip: got %v want %v", got.AlwaysOn, want.AlwaysOn)
}
}
func TestReadDaemonStateCorrupt(t *testing.T) {
@@ -152,51 +159,90 @@ func TestReadDaemonStateCorrupt(t *testing.T) {
}
}
// seedHeartbeat writes a connector heartbeat under dirKey for status tests.
func seedHeartbeat(t *testing.T, dirKey string, hb connectHeartbeat) string {
t.Helper()
dir, err := connectDaemonDir(dirKey)
if err != nil {
t.Fatalf("connectDaemonDir: %v", err)
}
data, err := json.MarshalIndent(hb, "", " ")
if err != nil {
t.Fatalf("marshal heartbeat: %v", err)
}
if err := os.WriteFile(connectHeartbeatPath(dir), data, 0o644); err != nil {
t.Fatalf("write heartbeat: %v", err)
}
return dir
}
func TestDaemonStatusNotRunning(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
var buf bytes.Buffer
if err := daemonStatus(&buf, "nope"); err != nil {
// No daemon pid file and no connector heartbeat.
if err := daemonStatus(&buf, "nope", false); err != nil {
t.Fatalf("daemonStatus: %v", err)
}
if !strings.Contains(buf.String(), "not running") {
t.Errorf("expected 'not running', got %q", buf.String())
if !strings.Contains(buf.String(), healthNotRunning) {
t.Errorf("expected %q, got %q", healthNotRunning, buf.String())
}
}
func TestDaemonStatusStalePid(t *testing.T) {
func TestDaemonStatusConnectorDown(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
dir, _ := connectDaemonDir("stale")
// pid that is essentially certain to be dead.
writeDaemonState(dir, daemonState{Pid: deadPid(t), StartUnix: time.Now().Unix(), LogPath: "/l", DirKey: "stale"})
// Heartbeat from a connector whose process is gone: down.
seedHeartbeat(t, "gone", connectHeartbeat{Pid: deadPid(t), StartUnix: time.Now().Unix() - 100, ConnectedUnix: time.Now().Unix() - 90})
var buf bytes.Buffer
if err := daemonStatus(&buf, "stale"); err != nil {
if err := daemonStatus(&buf, "gone", false); err != nil {
t.Fatalf("daemonStatus: %v", err)
}
if !strings.Contains(buf.String(), "stale pid file") {
t.Errorf("expected stale pid report, got %q", buf.String())
if !strings.Contains(buf.String(), healthDown) {
t.Errorf("expected %q, got %q", healthDown, buf.String())
}
}
func TestDaemonStatusRunning(t *testing.T) {
func TestDaemonStatusHealthy(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
// Live connector (our pid) that connected: healthy. Also file a live
// supervisor to exercise the supervised label.
dir, _ := connectDaemonDir("live")
// Use our own pid: guaranteed alive.
writeDaemonState(dir, daemonState{Pid: os.Getpid(), StartUnix: time.Now().Add(-90 * time.Second).Unix(), LogPath: "/l.log", DirKey: "live", ClientID: "cidX"})
seedHeartbeat(t, "live", connectHeartbeat{Pid: os.Getpid(), Channel: "opencode", ClientID: "cidX", StartUnix: time.Now().Unix() - 90, ConnectedUnix: time.Now().Unix() - 88, LastReplyUnix: time.Now().Unix() - 5})
var buf bytes.Buffer
if err := daemonStatus(&buf, "live"); err != nil {
if err := daemonStatus(&buf, "live", false); err != nil {
t.Fatalf("daemonStatus: %v", err)
}
out := buf.String()
for _, want := range []string{"running", "pid:", "uptime:", "/l.log", "cidX"} {
for _, want := range []string{healthHealthy, "pid:", "channel:", "opencode", "cidX", "super:"} {
if !strings.Contains(out, want) {
t.Errorf("status output missing %q; got %q", want, out)
}
}
}
func TestDaemonStatusJSON(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
seedHeartbeat(t, "j", connectHeartbeat{Pid: os.Getpid(), Channel: "codex", ClientID: "cidJ", StartUnix: time.Now().Unix() - 30, ConnectedUnix: time.Now().Unix() - 28})
var buf bytes.Buffer
if err := daemonStatus(&buf, "j", true); err != nil {
t.Fatalf("daemonStatus json: %v", err)
}
var report connectHealthReport
if err := json.Unmarshal(buf.Bytes(), &report); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
}
if report.State != healthHealthy {
t.Errorf("state = %q, want %q", report.State, healthHealthy)
}
if report.Channel != "codex" {
t.Errorf("channel = %q, want codex", report.Channel)
}
}
func TestDaemonStopNotRunning(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
+19
View File
@@ -0,0 +1,19 @@
//go:build !windows
package helpers
import (
"fmt"
"os"
"syscall"
)
func checkFDLimit() {
var rlim syscall.Rlimit
if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rlim); err != nil {
return
}
if rlim.Cur < 512 {
fmt.Fprintf(os.Stderr, "[connect][warn] 文件描述符上限 %d 偏低,多 agent 并发连接可能不稳定;建议 ulimit -n 1024\n", rlim.Cur)
}
}
@@ -0,0 +1,5 @@
//go:build windows
package helpers
func checkFDLimit() {}
+377
View File
@@ -0,0 +1,377 @@
// 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 (
"context"
"encoding/json"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
// connect_health is the "does the bot actually work" side of connect
// observability. The daemon (connect_daemon.go) answers "is a supervisor
// process alive"; the single-instance lock (connect_lock.go) answers "is there
// exactly one connector". Neither answers the question a developer actually
// asks — "is my connection live and receiving?" — which is the gap raised in
// the 0701 review: a foreground `connect` prints logs and looks busy, but if
// the Stream silently dropped there is no way to tell a working-but-idle
// connector from a dead one.
//
// The connector (runStreamConnector, foreground OR daemon worker) writes a
// heartbeat file recording its pid, when it last connected, and when it last
// received/answered a message. `connect status` reads it and derives a
// healthy/degraded/down verdict, and `--json` exposes the raw signal so an
// external supervisor (launchd / systemd / pm2 / cron watchdog) can decide
// whether to restart without guessing from the process table.
//
// Scope note (honest limitation, documented in the PR): with the Stream SDK's
// WithAutoReconnect(true) and no exposed disconnect callback, we cannot cheaply
// detect a silently-deaf connection beyond "process alive + connected at least
// once". lastPushAgoSec is surfaced as data, NOT as a degraded trigger, because
// an idle bot legitimately receives nothing for hours. The single-instance lock
// already rules out the duplicate-connection failure mode, so "alive +
// connected" is a strong healthy signal in practice.
const (
connectHeartbeatFile = "heartbeat.json"
connectHeartbeatFlush = 15 * time.Second
connectHealthStalePush = 5 * time.Minute // informational threshold only
)
// connectHeartbeat is the JSON persisted by a running connector. All times are
// unix seconds; a zero value means "never happened".
type connectHeartbeat struct {
Pid int `json:"pid"`
Channel string `json:"channel,omitempty"`
ClientID string `json:"clientId,omitempty"`
StartUnix int64 `json:"startUnix"`
ConnectedUnix int64 `json:"connectedUnix,omitempty"`
LastPushUnix int64 `json:"lastPushUnix,omitempty"`
LastReplyUnix int64 `json:"lastReplyUnix,omitempty"`
LastError string `json:"lastError,omitempty"`
LastErrorUnix int64 `json:"lastErrorUnix,omitempty"`
UpdatedUnix int64 `json:"updatedUnix"`
}
// connectHealth is the in-memory writer owned by a connector. Events update it
// in memory (cheap, called on the message hot path); a background ticker bumps
// UpdatedUnix and flushes on every tick — the periodic write IS the liveness
// proof consumed by the staleness check in deriveConnectHealth, so an idle
// connector must keep writing or it would be misreported as down (pid reuse).
type connectHealth struct {
dir string
mu sync.Mutex
hb connectHeartbeat
flushedUnix int64
}
// newConnectHealth builds a health writer filed under connect/<dirKey>/. Returns
// nil when no identity is available (dirKey empty) so every call site can treat
// health as best-effort — a nil *connectHealth's methods are all no-ops.
func newConnectHealth(clientID, channel string) *connectHealth {
dirKey := daemonDirKey(clientID, "")
if dirKey == "" {
return nil
}
dir, err := connectDaemonDir(dirKey)
if err != nil {
return nil
}
now := time.Now().Unix()
return &connectHealth{
dir: dir,
hb: connectHeartbeat{
Pid: os.Getpid(),
Channel: channel,
ClientID: clientID,
StartUnix: now,
UpdatedUnix: now,
},
}
}
func (h *connectHealth) touch(mutate func(*connectHeartbeat)) {
if h == nil {
return
}
h.mu.Lock()
mutate(&h.hb)
h.hb.UpdatedUnix = time.Now().Unix()
h.mu.Unlock()
}
// onConnected records a successful Stream connect. Called after cli.Start.
func (h *connectHealth) onConnected() {
h.touch(func(hb *connectHeartbeat) { hb.ConnectedUnix = time.Now().Unix() })
}
// onPush records an inbound message accepted for forwarding.
func (h *connectHealth) onPush() {
h.touch(func(hb *connectHeartbeat) { hb.LastPushUnix = time.Now().Unix() })
}
// onReply records a reply successfully produced by the agent.
func (h *connectHealth) onReply() {
h.touch(func(hb *connectHeartbeat) { hb.LastReplyUnix = time.Now().Unix() })
}
// onError records the most recent forward/delivery error.
func (h *connectHealth) onError(err error) {
if err == nil {
return
}
h.touch(func(hb *connectHeartbeat) {
hb.LastError = truncateRunes(err.Error(), 300)
hb.LastErrorUnix = time.Now().Unix()
})
}
// start writes an initial heartbeat and launches the flush ticker. It stops and
// removes the heartbeat file when ctx is cancelled, so a graceful shutdown
// leaves no stale "healthy" file behind.
func (h *connectHealth) start(ctx context.Context) {
if h == nil {
return
}
_ = h.flush()
go func() {
t := time.NewTicker(connectHeartbeatFlush)
defer t.Stop()
for {
select {
case <-ctx.Done():
h.remove()
return
case <-t.C:
// Each tick is a liveness proof: advance UpdatedUnix even when
// nothing else changed, otherwise an idle connector's heartbeat
// goes stale and deriveConnectHealth misreports it as down.
h.touch(func(*connectHeartbeat) {})
_ = h.flush()
}
}
}()
}
// flush persists the heartbeat only when it changed since the last write.
func (h *connectHealth) flush() error {
if h == nil {
return nil
}
h.mu.Lock()
if h.hb.UpdatedUnix == h.flushedUnix {
h.mu.Unlock()
return nil
}
snapshot := h.hb
h.mu.Unlock()
data, err := json.MarshalIndent(snapshot, "", " ")
if err != nil {
return err
}
path := connectHeartbeatPath(h.dir)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, config.FilePerm); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
return err
}
h.mu.Lock()
h.flushedUnix = snapshot.UpdatedUnix
h.mu.Unlock()
return nil
}
func (h *connectHealth) remove() {
if h == nil {
return
}
_ = os.Remove(connectHeartbeatPath(h.dir))
}
func connectHeartbeatPath(dir string) string {
return dir + string(os.PathSeparator) + connectHeartbeatFile
}
// readConnectHeartbeat loads the heartbeat file. Missing file yields (nil, nil)
// so callers distinguish "no connector ran" from an I/O error.
func readConnectHeartbeat(dir string) (*connectHeartbeat, error) {
data, err := os.ReadFile(connectHeartbeatPath(dir))
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var hb connectHeartbeat
if err := json.Unmarshal(data, &hb); err != nil {
return nil, err
}
return &hb, nil
}
// Health states, ordered worst-to-best for reporting.
const (
healthNotRunning = "not_running"
healthDown = "down"
healthDegraded = "degraded"
healthHealthy = "healthy"
)
// connectHealthReport is the derived, presentation-ready verdict combining the
// connector heartbeat with the (optional) supervising daemon.
type connectHealthReport struct {
State string `json:"state"`
Pid int `json:"pid,omitempty"`
Channel string `json:"channel,omitempty"`
ClientID string `json:"clientId,omitempty"`
AppName string `json:"appName,omitempty"`
UnifiedAppID string `json:"unifiedAppId,omitempty"`
UptimeSec int64 `json:"uptimeSec,omitempty"`
ConnectedAgo int64 `json:"connectedAgoSec,omitempty"`
LastPushAgoSec int64 `json:"lastPushAgoSec,omitempty"`
LastReplyAgo int64 `json:"lastReplyAgoSec,omitempty"`
LastError string `json:"lastError,omitempty"`
Supervised bool `json:"supervised"`
Detail string `json:"detail,omitempty"`
}
// deriveConnectHealth turns the raw heartbeat (and whether a daemon supervisor
// is alive) into a verdict. now is injected so the logic is unit-testable.
func deriveConnectHealth(hb *connectHeartbeat, supervised bool, now time.Time) connectHealthReport {
nowUnix := now.Unix()
if hb == nil {
return connectHealthReport{State: healthNotRunning, Supervised: supervised,
Detail: "no connector heartbeat found"}
}
r := connectHealthReport{
Pid: hb.Pid,
Channel: hb.Channel,
ClientID: hb.ClientID,
Supervised: supervised,
LastError: hb.LastError,
}
if hb.StartUnix > 0 {
r.UptimeSec = nowUnix - hb.StartUnix
}
if hb.ConnectedUnix > 0 {
r.ConnectedAgo = nowUnix - hb.ConnectedUnix
}
if hb.LastPushUnix > 0 {
r.LastPushAgoSec = nowUnix - hb.LastPushUnix
}
if hb.LastReplyUnix > 0 {
r.LastReplyAgo = nowUnix - hb.LastReplyUnix
}
// Connector process gone: down. A supervisor (if any) will restart it.
if hb.Pid <= 0 || !processAlive(hb.Pid) {
r.State = healthDown
if supervised {
r.Detail = "connector process not alive; supervisor should restart it"
} else {
r.Detail = "connector process not alive"
}
return r
}
// Guard against pid reuse: a live pid whose heartbeat is stale (no flush
// within 2× the flush interval) is not our connector.
heartbeatStaleThreshold := int64((2 * connectHeartbeatFlush).Seconds())
if hb.UpdatedUnix > 0 && (nowUnix-hb.UpdatedUnix) > heartbeatStaleThreshold {
r.State = healthDown
r.Detail = "heartbeat stale (pid may have been reused by another process)"
return r
}
// Alive but never reached a connected state: still starting or failing to
// establish the Stream.
if hb.ConnectedUnix == 0 {
r.State = healthDegraded
r.Detail = "process alive but never established a Stream connection"
return r
}
// Alive and connected, but the most recent event was an error with no
// successful activity after it: degraded.
lastOK := hb.ConnectedUnix
if hb.LastReplyUnix > lastOK {
lastOK = hb.LastReplyUnix
}
if hb.LastErrorUnix > lastOK {
r.State = healthDegraded
r.Detail = "last activity was an error after the last success"
return r
}
r.State = healthHealthy
return r
}
// connectBaseDir returns <configDir>/connect — the directory holding one
// subdirectory per connector (keyed by dirKey). Honours the test override.
func connectBaseDir() string {
base := connectDaemonDirOverride
if base == "" {
base = config.DefaultConfigDir()
}
return filepath.Join(base, "connect")
}
// listConnectors enumerates every connector on this machine by scanning
// connect/<dirKey>/ and deriving each one's health from its heartbeat plus any
// supervising daemon. This is the multi-connector view behind `connect list` —
// the same signal `status` reports for one robot, over all of them. Directories
// with neither a heartbeat nor a daemon state are skipped (empty leftovers).
// Results are sorted by clientId for stable output. now is injected for tests.
func listConnectors(now time.Time) ([]connectHealthReport, error) {
ents, err := os.ReadDir(connectBaseDir())
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []connectHealthReport
for _, e := range ents {
if !e.IsDir() {
continue
}
dir := filepath.Join(connectBaseDir(), e.Name())
hb, herr := readConnectHeartbeat(dir)
if herr != nil {
continue // unreadable heartbeat: skip rather than fail the whole list
}
st, _ := readDaemonState(dir)
if hb == nil && st == nil {
continue // empty leftover dir
}
supervised := st != nil && st.Pid > 0 && processAlive(st.Pid)
r := deriveConnectHealth(hb, supervised, now)
if r.ClientID == "" {
r.ClientID = e.Name() // fall back to the dir key when no heartbeat identity
}
if st != nil {
r.UnifiedAppID = st.UnifiedAppID
}
out = append(out, r)
}
sort.Slice(out, func(i, j int) bool { return out[i].ClientID < out[j].ClientID })
return out, nil
}
+245
View File
@@ -0,0 +1,245 @@
// 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"
"errors"
"os"
"testing"
"time"
)
func TestDeriveConnectHealth(t *testing.T) {
now := time.Unix(1_000_000, 0)
alive := os.Getpid()
dead := deadPid(t)
cases := []struct {
name string
hb *connectHeartbeat
supervised bool
want string
}{
{"no heartbeat", nil, false, healthNotRunning},
{"no heartbeat but supervised", nil, true, healthNotRunning},
{
"connector dead",
&connectHeartbeat{Pid: dead, StartUnix: now.Unix() - 100, ConnectedUnix: now.Unix() - 90},
false, healthDown,
},
{
"alive never connected",
&connectHeartbeat{Pid: alive, StartUnix: now.Unix() - 5},
false, healthDegraded,
},
{
"alive and connected",
&connectHeartbeat{Pid: alive, StartUnix: now.Unix() - 100, ConnectedUnix: now.Unix() - 90, LastReplyUnix: now.Unix() - 10},
false, healthHealthy,
},
{
"idle but connected is still healthy",
&connectHeartbeat{Pid: alive, StartUnix: now.Unix() - 100000, ConnectedUnix: now.Unix() - 100000},
false, healthHealthy,
},
{
// A long-idle connector whose ticker keeps refreshing UpdatedUnix
// must stay healthy — only a genuinely stale heartbeat means down.
"idle with fresh ticker heartbeat is healthy",
&connectHeartbeat{Pid: alive, StartUnix: now.Unix() - 100000, ConnectedUnix: now.Unix() - 100000, UpdatedUnix: now.Unix() - 10},
false, healthHealthy,
},
{
"error after last success is degraded",
&connectHeartbeat{Pid: alive, StartUnix: now.Unix() - 100, ConnectedUnix: now.Unix() - 90, LastReplyUnix: now.Unix() - 50, LastErrorUnix: now.Unix() - 5, LastError: "boom"},
false, healthDegraded,
},
{
"error before last reply stays healthy",
&connectHeartbeat{Pid: alive, StartUnix: now.Unix() - 100, ConnectedUnix: now.Unix() - 90, LastErrorUnix: now.Unix() - 50, LastReplyUnix: now.Unix() - 5, LastError: "old", UpdatedUnix: now.Unix() - 5},
false, healthHealthy,
},
{
"stale heartbeat from pid reuse is down",
&connectHeartbeat{Pid: alive, StartUnix: now.Unix() - 1000, ConnectedUnix: now.Unix() - 900, UpdatedUnix: now.Unix() - 60},
false, healthDown,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := deriveConnectHealth(c.hb, c.supervised, now)
if got.State != c.want {
t.Fatalf("state = %q, want %q (detail=%q)", got.State, c.want, got.Detail)
}
if got.Supervised != c.supervised {
t.Errorf("supervised = %v, want %v", got.Supervised, c.supervised)
}
})
}
}
func TestConnectHeartbeatRoundTrip(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
h := newConnectHealth("cid-round", "opencode")
if h == nil {
t.Fatal("newConnectHealth returned nil for a valid clientId")
}
h.onConnected()
h.onPush()
h.onReply()
if err := h.flush(); err != nil {
t.Fatalf("flush: %v", err)
}
hb, err := readConnectHeartbeat(h.dir)
if err != nil {
t.Fatalf("read: %v", err)
}
if hb == nil {
t.Fatal("heartbeat not persisted")
}
if hb.Pid != os.Getpid() || hb.Channel != "opencode" || hb.ClientID != "cid-round" {
t.Errorf("unexpected heartbeat identity: %+v", hb)
}
if hb.ConnectedUnix == 0 || hb.LastPushUnix == 0 || hb.LastReplyUnix == 0 {
t.Errorf("expected all activity timestamps set: %+v", hb)
}
}
func TestConnectHeartbeatFlushSkipsUnchanged(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
h := newConnectHealth("cid-skip", "codex")
h.onConnected()
if err := h.flush(); err != nil {
t.Fatalf("flush: %v", err)
}
// Second flush with no new event must be a no-op (nothing to write).
fi1, _ := os.Stat(connectHeartbeatPath(h.dir))
if err := h.flush(); err != nil {
t.Fatalf("second flush: %v", err)
}
if h.hb.UpdatedUnix != h.flushedUnix {
t.Errorf("flushedUnix (%d) should track UpdatedUnix (%d) after flush", h.flushedUnix, h.hb.UpdatedUnix)
}
_ = fi1
}
// Regression for the v1.0.50-preview idle false-down: the flush ticker must
// advance UpdatedUnix on every tick (bare touch) so an idle connector keeps
// proving liveness; without it the heartbeat goes stale and
// deriveConnectHealth misreports the connector as down after 30s.
func TestConnectHeartbeatIdleTickKeepsFresh(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
h := newConnectHealth("cid-idle", "codex")
if h.hb.UpdatedUnix == 0 {
t.Fatal("newConnectHealth must seed UpdatedUnix so the initial flush persists")
}
h.onConnected()
if err := h.flush(); err != nil {
t.Fatalf("initial flush: %v", err)
}
if hb, err := readConnectHeartbeat(h.dir); err != nil || hb == nil {
t.Fatalf("initial heartbeat not persisted: hb=%v err=%v", hb, err)
}
// Backdate the heartbeat as if 40 idle seconds passed (beyond the 30s
// staleness threshold), then do exactly what the ticker does: a bare
// touch plus flush.
h.mu.Lock()
backdated := h.hb.UpdatedUnix - 40
h.hb.UpdatedUnix = backdated
h.flushedUnix = backdated
h.mu.Unlock()
h.touch(func(*connectHeartbeat) {})
if err := h.flush(); err != nil {
t.Fatalf("tick flush: %v", err)
}
hb, err := readConnectHeartbeat(h.dir)
if err != nil || hb == nil {
t.Fatalf("tick heartbeat not persisted: hb=%v err=%v", hb, err)
}
if hb.UpdatedUnix <= backdated {
t.Fatalf("bare tick touch must advance persisted UpdatedUnix past %d, got %d", backdated, hb.UpdatedUnix)
}
if got := deriveConnectHealth(hb, false, time.Now()); got.State != healthHealthy {
t.Fatalf("idle connector with ticker-fresh heartbeat = %q (detail=%q), want %q", got.State, got.Detail, healthHealthy)
}
}
func TestConnectHealthNilSafe(t *testing.T) {
var h *connectHealth // no clientId path yields nil
// None of these may panic.
h.onConnected()
h.onPush()
h.onReply()
h.onError(errors.New("x"))
h.start(nil)
if err := h.flush(); err != nil {
t.Fatalf("nil flush: %v", err)
}
h.remove()
}
func TestNewConnectHealthNoIdentity(t *testing.T) {
if h := newConnectHealth("", ""); h != nil {
t.Errorf("expected nil health writer with no clientId, got %+v", h)
}
}
func TestListConnectors(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
now := time.Unix(2_000_000, 0)
alive := os.Getpid()
dirA, _ := connectDaemonDir("dingAAA")
writeJSON(t, connectHeartbeatPath(dirA), connectHeartbeat{Pid: alive, Channel: "opencode", ClientID: "dingAAA", StartUnix: now.Unix() - 100, ConnectedUnix: now.Unix() - 90})
dirB, _ := connectDaemonDir("dingBBB")
writeJSON(t, connectHeartbeatPath(dirB), connectHeartbeat{Pid: deadPid(t), Channel: "codex", ClientID: "dingBBB", StartUnix: now.Unix() - 50, ConnectedUnix: now.Unix() - 40})
// Empty leftover dir must be skipped.
_, _ = connectDaemonDir("emptyleftover")
reports, err := listConnectors(now)
if err != nil {
t.Fatalf("listConnectors: %v", err)
}
if len(reports) != 2 {
t.Fatalf("got %d reports, want 2 (empty dir skipped): %+v", len(reports), reports)
}
if reports[0].ClientID != "dingAAA" || reports[0].State != healthHealthy {
t.Errorf("report[0] = %+v, want dingAAA healthy", reports[0])
}
if reports[1].ClientID != "dingBBB" || reports[1].State != healthDown {
t.Errorf("report[1] = %+v, want dingBBB down", reports[1])
}
}
func writeJSON(t *testing.T, path string, v any) {
t.Helper()
data, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatal(err)
}
}
+280
View File
@@ -22,6 +22,7 @@ import (
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/google/uuid"
@@ -47,6 +48,212 @@ func pictureDownloadCode(content interface{}) string {
return ""
}
// fileInboundInfo carries everything a msgtype="file" callback might expose.
// Client-sent files (a user attaching a file in the DingTalk client) surface
// DownloadCode + FileName; API-sent files (`dws chat message send --msg-type
// file --dentry-id --space-id`) surface DentryID + SpaceID + FileName +
// FileType + FileSize + FilePath and NO DownloadCode. Both shapes have to be
// recognisable or the connector silently drops legitimate file messages.
type fileInboundInfo struct {
DownloadCode string
FileName string
FileType string
FilePath string
DentryID int64
SpaceID int64
FileSize int64
}
func (f fileInboundInfo) hasActionable() bool {
return strings.TrimSpace(f.DownloadCode) != "" || (f.DentryID != 0 && f.SpaceID != 0)
}
// parseFileInbound reads every relevant field out of a file callback's
// content payload (msgtype="file"). The content is a loosely-typed
// map[string]interface{}; numeric fields (dentryId/spaceId/fileSize) can be
// JSON strings or numbers depending on which endpoint sent the message, so
// both branches are handled.
func parseFileInbound(content interface{}) fileInboundInfo {
info := fileInboundInfo{}
m, ok := content.(map[string]interface{})
if !ok {
return info
}
for _, key := range []string{"downloadCode", "fileDownloadCode"} {
if v, ok := m[key].(string); ok && strings.TrimSpace(v) != "" {
info.DownloadCode = strings.TrimSpace(v)
break
}
}
if v, ok := m["fileName"].(string); ok {
info.FileName = strings.TrimSpace(v)
}
if v, ok := m["fileType"].(string); ok {
info.FileType = strings.TrimSpace(v)
}
if v, ok := m["filePath"].(string); ok {
info.FilePath = strings.TrimSpace(v)
}
info.DentryID = readInt64Field(m, "dentryId", "dentryID")
info.SpaceID = readInt64Field(m, "spaceId", "spaceID")
info.FileSize = readInt64Field(m, "fileSize", "size")
if info.FileName == "" {
info.FileName = "未知文件"
}
return info
}
// readInt64Field pulls an int64 out of the loose content map under any of the
// provided keys, tolerating JSON string / float64 / int64 / json.Number.
func readInt64Field(m map[string]interface{}, keys ...string) int64 {
for _, key := range keys {
v, ok := m[key]
if !ok {
continue
}
switch t := v.(type) {
case string:
if s := strings.TrimSpace(t); s != "" {
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
return n
}
}
case float64:
return int64(t)
case int64:
return t
case int:
return int64(t)
case json.Number:
if n, err := t.Int64(); err == nil {
return n
}
}
}
return 0
}
// fileDownloadInfo preserves the two-value shape used by legacy callers that
// only care about the downloadCode / fileName pair.
func fileDownloadInfo(content interface{}) (downloadCode, fileName string) {
info := parseFileInbound(content)
return info.DownloadCode, info.FileName
}
// summarizeContent renders the callback content into a short one-line string
// for stderr diagnostics (used when a file/other callback is being dropped
// so the operator can tell why after the fact).
func summarizeContent(content interface{}) string {
if content == nil {
return "<nil>"
}
b, err := json.Marshal(content)
if err != nil {
return fmt.Sprintf("<unmarshalable:%v>", err)
}
s := string(b)
if len(s) > 400 {
s = s[:400] + "…"
}
return s
}
// extractCallbackText pulls the visible text out of a structured-text callback
// payload (msgtype=richText / markdown / etc.) for the case where the SDK's
// data.Text.Content is empty. This matters because `dws chat message send
// --group ... --text ...` sends msgType="markdown" by default, and DingTalk's
// stream callback for markdown messages routinely leaves data.Text.Content
// blank while stashing the real body in data.Content (loosely-typed).
// Returns "" if no text-shaped field is found.
func extractCallbackText(content interface{}) string {
switch v := content.(type) {
case string:
return strings.TrimSpace(v)
case map[string]interface{}:
// Common shapes: {"text":"..."}, {"title":"...","text":"..."},
// {"content":"..."}, richText {"richText":[{"text":"..."}]}.
for _, key := range []string{"text", "content", "markdown", "title"} {
if s, ok := v[key].(string); ok && strings.TrimSpace(s) != "" {
return strings.TrimSpace(s)
}
}
if arr, ok := v["richText"].([]interface{}); ok {
var b strings.Builder
for _, item := range arr {
if m, ok := item.(map[string]interface{}); ok {
if s, ok := m["text"].(string); ok {
b.WriteString(s)
}
}
}
if s := strings.TrimSpace(b.String()); s != "" {
return s
}
}
// interactiveCard: another bot @-mentioning this bot arrives as
// msgtype="interactiveCard" with the body nested in
// content.cardContent[].children[].value (elementType TEXT). The SDK
// leaves Text.Content blank, so without this the connector drops a
// legitimate bot-to-bot @ message.
if s := extractCardContentText(v["cardContent"]); s != "" {
return s
}
}
return ""
}
// cardContentLeaves flattens an interactiveCard cardContent tree into its
// ordered TEXT leaf values. Shape (verified live): cardContent is an array of
// blocks, each with a "children" array of {elementType:"TEXT", value:"..."}
// leaves; nested blocks recurse via their own "children".
func cardContentLeaves(v interface{}) []string {
arr, ok := v.([]interface{})
if !ok {
return nil
}
var leaves []string
var walk func(items []interface{})
walk = func(items []interface{}) {
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
if s, ok := m["value"].(string); ok && s != "" {
leaves = append(leaves, s)
}
if kids, ok := m["children"].([]interface{}); ok {
walk(kids)
}
}
}
walk(arr)
return leaves
}
// extractCardContentText joins all interactiveCard leaves — the raw body,
// mention included. Used by the generic extractCallbackText fallback.
func extractCardContentText(v interface{}) string {
return strings.TrimSpace(strings.Join(cardContentLeaves(v), ""))
}
// extractInteractiveCardText returns the interactiveCard body with the leading
// @-mention removed. A bot @-ing another bot renders the mention as its own
// leading "@name" TEXT leaf (the display name may itself contain spaces, so we
// drop by leaf boundary, not by whitespace), followed by the real message in
// the next leaf. Returns "" when there is nothing beyond the mention.
func extractInteractiveCardText(content interface{}) string {
m, ok := content.(map[string]interface{})
if !ok {
return ""
}
leaves := cardContentLeaves(m["cardContent"])
for len(leaves) > 0 && strings.HasPrefix(strings.TrimSpace(leaves[0]), "@") {
leaves = leaves[1:]
}
return strings.TrimSpace(strings.Join(leaves, ""))
}
// downloadMessageFile resolves a chatbot media callback (picture etc.) to a
// local temp file via the robot messageFiles/download API. Error-screenshot
// questions are the top Q&A inbound; without this the connector silently
@@ -94,6 +301,79 @@ func (c *aiCardClient) downloadMessageFile(ctx context.Context, robotCode, downl
return dest, nil
}
// getUserUnionID resolves a staffId (userId) to unionId via the contact API.
// The connector needs unionId to call the storage/drive download APIs.
func (c *aiCardClient) getUserUnionID(ctx context.Context, userID string) (string, error) {
raw, err := c.callRaw(ctx, http.MethodGet, "/v1.0/contact/users/"+userID, nil)
if err != nil {
return "", fmt.Errorf("contact/users/%s: %w", userID, err)
}
var parsed struct {
UnionID string `json:"unionId"`
}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil || strings.TrimSpace(parsed.UnionID) == "" {
return "", fmt.Errorf("contact/users/%s 未返回 unionId: %s", userID, truncateRunes(raw, 200))
}
return parsed.UnionID, nil
}
// downloadDentryFile downloads a file from DingTalk storage by numeric
// dentryId + spaceId (the shape API-sent file callbacks provide). It resolves
// download info via the v2.0 storage API and saves the file to a local temp
// path, returning the path.
func (c *aiCardClient) downloadDentryFile(ctx context.Context, spaceID, dentryID int64, unionID, fileName string) (string, error) {
path := fmt.Sprintf("/v2.0/storage/spaces/%d/dentries/%d/getDownloadInfo", spaceID, dentryID)
raw, err := c.callRaw(ctx, http.MethodPost, path, map[string]any{
"unionId": unionID,
})
if err != nil {
return "", fmt.Errorf("getDownloadInfo spaceId=%d dentryId=%d: %w", spaceID, dentryID, err)
}
var parsed struct {
ResourceURL string `json:"resourceUrl"`
HeadersMap map[string]string `json:"headers"`
}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil || strings.TrimSpace(parsed.ResourceURL) == "" {
return "", fmt.Errorf("getDownloadInfo 未返回 resourceUrl: %s", truncateRunes(raw, 200))
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.ResourceURL, nil)
if err != nil {
return "", err
}
for k, v := range parsed.HeadersMap {
req.Header.Set(k, v)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", fmt.Errorf("钉盘文件下载 HTTP %d", resp.StatusCode)
}
dir := filepath.Join(os.TempDir(), "dws-connect-media")
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
ext := filepath.Ext(fileName)
if ext == "" {
ext = mediaExt(parsed.ResourceURL, resp.Header.Get("Content-Type"))
}
dest := filepath.Join(dir, uuid.NewString()+ext)
f, err := os.Create(dest)
if err != nil {
return "", err
}
defer f.Close()
if _, err := io.Copy(f, io.LimitReader(resp.Body, mediaMaxDownloadBytes)); err != nil {
_ = os.Remove(dest)
return "", err
}
return dest, nil
}
// mediaExt picks a file extension from the response content type, falling
// back to the URL path, then ".png" (DingTalk screenshots default to png).
func mediaExt(rawURL, contentType string) string {
+270
View File
@@ -42,6 +42,81 @@ func TestPictureDownloadCode(t *testing.T) {
}
}
// TestExtractCallbackText covers the markdown / richText fallback path used
// when SDK data.Text.Content is empty. This is the recovery path for
// `dws chat message send --group ... --text ...` (defaults to msgType=markdown)
// which otherwise gets silently dropped by the connector.
func TestExtractCallbackText(t *testing.T) {
cases := []struct {
name string
content interface{}
want string
}{
{"raw string", "hello", "hello"},
{"text field", map[string]interface{}{"text": "hi"}, "hi"},
{"text preferred over title", map[string]interface{}{"title": "标题", "text": "body"}, "body"},
{"content field", map[string]interface{}{"content": "raw"}, "raw"},
{"markdown field", map[string]interface{}{"markdown": "**bold**"}, "**bold**"},
{"title only", map[string]interface{}{"title": "只有标题"}, "只有标题"},
{"richText array", map[string]interface{}{"richText": []interface{}{
map[string]interface{}{"text": "part1 "},
map[string]interface{}{"text": "part2"},
}}, "part1 part2"},
{"whitespace trimmed", map[string]interface{}{"text": " spaced "}, "spaced"},
{"nil returns empty", nil, ""},
{"unknown shape returns empty", map[string]interface{}{"foo": "bar"}, ""},
{"empty text falls through", map[string]interface{}{"text": "", "content": "backup"}, "backup"},
// interactiveCard (bot @-mentioning this bot): the real payload shape
// captured live — body nested in cardContent[].children[].value.
{"interactiveCard cardContent", map[string]interface{}{"cardContent": []interface{}{
map[string]interface{}{"elementType": "RICHTEXT", "children": []interface{}{
map[string]interface{}{"elementType": "TEXT", "value": "@claudecode 助手"},
map[string]interface{}{"elementType": "TEXT", "value": " 请从 1 数到 10"},
}},
}}, "@claudecode 助手 请从 1 数到 10"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := extractCallbackText(tc.content); got != tc.want {
t.Fatalf("got %q, want %q", got, tc.want)
}
})
}
}
// TestExtractInteractiveCardText covers the bot→bot @ card: the leading
// mention leaf (whose display name may contain spaces) is dropped by leaf
// boundary, leaving the clean instruction.
func TestExtractInteractiveCardText(t *testing.T) {
card := func(leaves ...string) interface{} {
kids := make([]interface{}, 0, len(leaves))
for _, l := range leaves {
kids = append(kids, map[string]interface{}{"elementType": "TEXT", "value": l})
}
return map[string]interface{}{"cardContent": []interface{}{
map[string]interface{}{"elementType": "RICHTEXT", "children": kids},
}}
}
cases := []struct {
name string
content interface{}
want string
}{
{"mention leaf dropped", card("@claudecode 助手", " 请从 1 数到 10"), "请从 1 数到 10"},
{"no mention", card("直接说的话"), "直接说的话"},
{"only mention", card("@claudecode 助手"), ""},
{"non-map content", "plain", ""},
{"nil content", nil, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := extractInteractiveCardText(tc.content); got != tc.want {
t.Fatalf("got %q, want %q", got, tc.want)
}
})
}
}
// TestDownloadMessageFile drives the full resolve-then-fetch flow against a
// fake API: token → messageFiles/download (must carry robotCode+downloadCode)
// → presigned GET → local temp file.
@@ -117,3 +192,198 @@ func TestMediaExt(t *testing.T) {
}
}
}
// TestParseFileInbound covers both callback shapes so the regression that
// dropped every API-sent file (dentryId/spaceId, no downloadCode) can't
// silently return: (a) client-sent shape carrying downloadCode + fileName,
// (b) API-sent shape carrying dentryId + spaceId as JSON string OR number,
// (c) mixed / unknown / nil shapes must degrade to hasActionable()=false.
func TestParseFileInbound(t *testing.T) {
cases := []struct {
name string
content interface{}
wantCode string
wantName string
wantDentry int64
wantSpace int64
wantActionable bool
}{
{
name: "client-sent downloadCode",
content: map[string]interface{}{"downloadCode": "dc-1", "fileName": "screenshot.png"},
wantCode: "dc-1",
wantName: "screenshot.png",
wantActionable: true,
},
{
name: "client-sent fileDownloadCode alias",
content: map[string]interface{}{"fileDownloadCode": "dc-2", "fileName": "log.txt"},
wantCode: "dc-2",
wantName: "log.txt",
wantActionable: true,
},
{
name: "API-sent dentryId/spaceId as numbers",
content: map[string]interface{}{
"dentryId": float64(123456789),
"spaceId": float64(987654321),
"fileName": "report.pdf",
},
wantName: "report.pdf",
wantDentry: 123456789,
wantSpace: 987654321,
wantActionable: true,
},
{
name: "API-sent dentryId/spaceId as strings (real callback shape)",
content: map[string]interface{}{
"dentryId": "11111",
"spaceId": "22222",
"fileName": "spec.docx",
},
wantName: "spec.docx",
wantDentry: 11111,
wantSpace: 22222,
wantActionable: true,
},
{
name: "default fileName when missing",
content: map[string]interface{}{"downloadCode": "dc-3"},
wantCode: "dc-3",
wantName: "未知文件",
wantActionable: true,
},
{
name: "no downloadCode + only dentry (missing space) is NOT actionable",
content: map[string]interface{}{"dentryId": float64(1), "fileName": "x"},
wantName: "x",
wantDentry: 1,
wantActionable: false,
},
{
name: "wrong type returns empty",
content: "not-a-map",
wantName: "",
wantActionable: false,
},
{
name: "nil returns empty",
content: nil,
wantName: "",
wantActionable: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := parseFileInbound(tc.content)
if got.DownloadCode != tc.wantCode {
t.Errorf("DownloadCode = %q, want %q", got.DownloadCode, tc.wantCode)
}
if got.FileName != tc.wantName {
t.Errorf("FileName = %q, want %q", got.FileName, tc.wantName)
}
if got.DentryID != tc.wantDentry {
t.Errorf("DentryID = %d, want %d", got.DentryID, tc.wantDentry)
}
if got.SpaceID != tc.wantSpace {
t.Errorf("SpaceID = %d, want %d", got.SpaceID, tc.wantSpace)
}
if got.hasActionable() != tc.wantActionable {
t.Errorf("hasActionable() = %v, want %v", got.hasActionable(), tc.wantActionable)
}
})
}
}
// TestFileDownloadInfoBackCompat verifies the legacy two-value wrapper still
// works so any other caller relying on it isn't broken by the refactor.
func TestFileDownloadInfoBackCompat(t *testing.T) {
code, name := fileDownloadInfo(map[string]interface{}{
"downloadCode": "dc-legacy",
"fileName": "legacy.doc",
})
if code != "dc-legacy" || name != "legacy.doc" {
t.Fatalf("legacy wrapper = %q/%q, want dc-legacy/legacy.doc", code, name)
}
}
func TestSummarizeContent(t *testing.T) {
if got := summarizeContent(nil); got != "<nil>" {
t.Fatalf("nil summary = %q", got)
}
got := summarizeContent(map[string]interface{}{"dentryId": "1", "fileName": "x"})
if !strings.Contains(got, "dentryId") || !strings.Contains(got, "fileName") {
t.Fatalf("summary %q missing keys", got)
}
}
func TestGetUserUnionID(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"accessToken": "tok-1", "expireIn": 7200})
})
mux.HandleFunc("/v1.0/contact/users/user-123", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("want GET, got %s", r.Method)
}
if r.Header.Get("x-acs-dingtalk-access-token") != "tok-1" {
t.Error("missing access token")
}
_ = json.NewEncoder(w).Encode(map[string]any{"unionId": "union-abc"})
})
srv := httptest.NewServer(mux)
defer srv.Close()
withCardAPIBase(t, srv.URL)
c := newAICardClient("ding-client", "ding-secret", "")
uid, err := c.getUserUnionID(context.Background(), "user-123")
if err != nil {
t.Fatalf("getUserUnionID: %v", err)
}
if uid != "union-abc" {
t.Fatalf("unionId = %q, want union-abc", uid)
}
}
func TestDownloadDentryFile(t *testing.T) {
mux := http.NewServeMux()
var srv *httptest.Server
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"accessToken": "tok-1", "expireIn": 7200})
})
mux.HandleFunc("/v2.0/storage/spaces/999/dentries/123/getDownloadInfo", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("want POST, got %s", r.Method)
}
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if body["unionId"] != "union-abc" {
t.Errorf("unionId = %v, want union-abc", body["unionId"])
}
_ = json.NewEncoder(w).Encode(map[string]any{
"resourceUrl": srv.URL + "/dl/report.pdf",
})
})
mux.HandleFunc("/dl/report.pdf", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/pdf")
_, _ = w.Write([]byte("%PDF-fake"))
})
srv = httptest.NewServer(mux)
defer srv.Close()
withCardAPIBase(t, srv.URL)
c := newAICardClient("ding-client", "ding-secret", "")
path, err := c.downloadDentryFile(context.Background(), 999, 123, "union-abc", "report.pdf")
if err != nil {
t.Fatalf("downloadDentryFile: %v", err)
}
defer os.Remove(path)
if !strings.HasSuffix(path, ".pdf") {
t.Fatalf("path = %q, want .pdf suffix", path)
}
raw, err := os.ReadFile(path)
if err != nil || string(raw) != "%PDF-fake" {
t.Fatalf("file content = %q, %v", raw, err)
}
}
+4 -5
View File
@@ -96,7 +96,7 @@ func (f *opencodeForwarder) forward(ctx context.Context, convID, text string) (s
}
func (f *opencodeForwarder) forwardStream(ctx context.Context, convID, text string, _ func(string)) (string, error) {
ctx, cancel := context.WithTimeout(ctx, f.timeout)
ctx, cancel := applyTimeout(ctx, f.timeout)
defer cancel()
client, err := f.server.ensure(ctx)
@@ -204,10 +204,9 @@ func newOpencodeServer(bin string, env []string, workDir string) *opencodeServer
bin: bin,
env: env,
workDir: workDir,
// No client-level Timeout: a turn can legitimately run for minutes, so the
// per-request ctx (forwardStream's f.timeout, default 300s) governs instead.
// A hardcoded 30s here used to abort long agent replies mid-flight with
// "Client.Timeout exceeded while awaiting headers".
// No client-level Timeout: a turn can legitimately run for minutes.
// f.timeout (from --agent-timeout / DWS_AGENT_TIMEOUT_MS, default 0 =
// no limit) governs via the per-request ctx when set.
httpClient: &http.Client{},
}
}
+65 -5
View File
@@ -16,6 +16,7 @@ package helpers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
@@ -246,17 +247,17 @@ func TestOpencodeSessionsPersist(t *testing.T) {
}
}
// TestNewOpencodeServerHasNoClientTimeout pins the fix: the shared HTTP client
// must not carry an overall deadline, otherwise long agent turns get aborted
// with "Client.Timeout exceeded while awaiting headers". The per-turn ctx is
// the only thing allowed to bound a message round-trip.
// TestNewOpencodeServerHasNoClientTimeout pins: the shared HTTP client
// must not carry an overall deadline — long agent turns would be aborted
// mid-flight. The per-turn ctx (f.timeout, default 0 = no limit) governs
// only when the user explicitly sets --agent-timeout / DWS_AGENT_TIMEOUT_MS.
func TestNewOpencodeServerHasNoClientTimeout(t *testing.T) {
s := newOpencodeServer("opencode", nil, "")
if s.httpClient == nil {
t.Fatal("httpClient must be initialized")
}
if s.httpClient.Timeout != 0 {
t.Fatalf("opencode http client Timeout = %s, want 0 (per-turn ctx governs long agent replies)", s.httpClient.Timeout)
t.Fatalf("opencode http client Timeout = %s, want 0 (no client-level cap)", s.httpClient.Timeout)
}
}
@@ -314,6 +315,65 @@ func TestOpencodeForwarderMessageGovernedByTurnCtx(t *testing.T) {
}
}
// TestOpencodeForwarderContextDeadlineExceededRepro reproduces the error seen
// in the wild: user sends "run tech report", the opencode server processes for
// longer than the user-configured turn timeout, and the context deadline kills
// the POST /session/:id/message. The error must surface "context deadline
// exceeded" so the higher-level formatter produces a meaningful reply.
func TestOpencodeForwarderContextDeadlineExceededRepro(t *testing.T) {
dir := t.TempDir()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/global/health":
_, _ = w.Write([]byte(`{"healthy":true}`))
case r.URL.Path == "/session":
_, _ = w.Write([]byte(`{"id":"ses_1065c16edffeC46Oie6v9WY11l"}`))
case r.URL.Path == "/session/ses_1065c16edffeC46Oie6v9WY11l/message":
// Simulate a long-running agent that never responds within the turn budget.
// Fallback timeout ensures ts.Close() does not hang waiting on the handler.
select {
case <-r.Context().Done():
case <-time.After(1 * time.Second):
}
default:
http.NotFound(w, r)
}
}))
defer ts.Close()
f := &opencodeForwarder{
bin: "opencode",
timeout: 100 * time.Millisecond, // short turn budget to trigger deadline quickly
workDir: dir,
sessions: newOpencodeSessions(filepath.Join(dir, "s.json")),
server: &opencodeServer{baseURL: ts.URL, httpClient: &http.Client{}},
}
_, err := f.forwardStream(context.Background(), "conv-1", "run tech report", nil)
if err == nil {
t.Fatal("expected context deadline exceeded error, got nil")
}
errStr := err.Error()
if !strings.Contains(errStr, "context deadline exceeded") {
t.Fatalf("error = %q, want it to contain 'context deadline exceeded'", errStr)
}
if !strings.Contains(errStr, "/session/ses_1065c16edffeC46Oie6v9WY11l/message") {
t.Fatalf("error = %q, want it to contain the session message path", errStr)
}
// Verify the higher-level format matches the screenshot.
formatted := fmt.Sprintf("(opencode 调用失败:%v)", err)
if !strings.Contains(formatted, "context deadline exceeded") {
t.Fatalf("formatted reply = %q, want 'context deadline exceeded'", formatted)
}
if !strings.HasPrefix(formatted, "(opencode 调用失败:") {
t.Fatalf("formatted reply = %q, want prefix '(opencode 调用失败:'", formatted)
}
}
// opencodeForwarder must satisfy sessionClearer so /clear gets a real delete.
var _ sessionClearer = (*opencodeForwarder)(nil)
+1 -1
View File
@@ -79,7 +79,7 @@ func (f *qoderStreamForwarder) forward(ctx context.Context, convID, text string)
}
func (f *qoderStreamForwarder) forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
ctx, cancel := context.WithTimeout(ctx, f.timeout)
ctx, cancel := applyTimeout(ctx, f.timeout)
defer cancel()
f.mu.Lock()
+171 -14
View File
@@ -16,6 +16,7 @@ package helpers
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
@@ -187,6 +188,9 @@ type connectAgentOptions struct {
// "remember" asks once per action kind then reuses that decision. Sourced
// from RoleConfig.confirm_policy; empty = manual.
ConfirmPolicy string
// Timeout caps each agent turn (--agent-timeout seconds /
// DWS_AGENT_TIMEOUT_MS milliseconds). 0 = no limit (default).
Timeout time.Duration
}
// isStreamBridgeChannel reports whether a channel is wired through the Go-native
@@ -239,7 +243,7 @@ func (f *execForwarder) label() string {
}
func (f *execForwarder) forward(ctx context.Context, convID, text string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, f.timeout)
ctx, cancel := applyTimeout(ctx, f.timeout)
defer cancel()
// Session args go right after the binary, before the spec tail — some specs
// (qoder) end the tail with `-p` so the prompt must stay the trailing
@@ -765,7 +769,10 @@ func resolveExecAgent(channel string) (argv []string, env []string, err error) {
// dependency on a live interactive session. opts applies the user-facing agent
// tuning (--agent-model / --agent-workdir / --agent-memory).
func forwarderForChannel(channel, clientID string, opts connectAgentOptions) (forwarder, error) {
timeout := envDurationMS("DWS_AGENT_TIMEOUT_MS", 300*time.Second)
timeout := opts.Timeout
if timeout <= 0 {
timeout = envDurationMS("DWS_AGENT_TIMEOUT_MS", 0)
}
spec, ok := agentSpecs[channel]
if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf("渠道 %q 不是 stream-bridge 渠道,无 forwarder", channel))
@@ -909,6 +916,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
if extras == nil {
extras = &connectExtras{}
}
checkFDLimit()
if closer, ok := fwd.(forwarderCloser); ok {
defer func() {
if err := closer.close(); err != nil {
@@ -925,6 +933,12 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
}
defer release()
// Health heartbeat: record connect/receive/reply/error so `connect status`
// can tell a live connector from a dead one (see connect_health.go). Nil
// when no clientId identity is available; all calls below are no-ops then.
health := newConnectHealth(clientID, channel)
health.start(ctx)
streamLoggerOnce.Do(func() { sdklogger.SetLogger(streamSDKLogger{}) })
replier := chatbot.NewChatbotReplier()
dedup := newMsgDedup(10000)
@@ -936,19 +950,58 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
mediaCli = newAICardClient(clientID, clientSecret, "")
}
keepAlive := envDurationMS("DWS_CONNECT_KEEPALIVE_MS", 30000)
fmt.Fprintf(os.Stderr, "[connect] keepAlive=%s autoReconnect=true\n", keepAlive)
cli := client.NewStreamClient(
client.WithAppCredential(client.NewAppCredentialConfig(clientID, clientSecret)),
client.WithAutoReconnect(true),
client.WithKeepAlive(keepAlive),
)
cli.RegisterChatBotCallbackRouter(func(_ context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
text := strings.TrimSpace(data.Text.Content)
msgtype := strings.TrimSpace(data.Msgtype)
// Picture messages carry no text — their payload is a downloadCode
// resolved to a local file in the forward goroutine below.
picCode := ""
if strings.EqualFold(strings.TrimSpace(data.Msgtype), "picture") {
if strings.EqualFold(msgtype, "picture") {
picCode = pictureDownloadCode(data.Content)
}
if (text == "" && picCode == "") || data.SessionWebhook == "" {
// File callbacks come in two shapes: client-sent files carry a
// downloadCode; API-sent files (`dws chat message send --msg-type file
// --dentry-id --space-id`) carry dentryId + spaceId instead and have
// NO downloadCode. Both have to be recognisable or legit file messages
// get silently dropped below.
var fileInfo fileInboundInfo
if strings.EqualFold(msgtype, "file") {
fileInfo = parseFileInbound(data.Content)
}
// Structured-text fallback: DingTalk leaves data.Text.Content blank on
// markdown / richText callbacks (the body ships in data.Content). Without
// this, `dws chat message send --group ... --text ...` — which defaults
// to msgType=markdown — hits the drop branch below and the bot looks
// dead to the sender.
if text == "" && picCode == "" {
// interactiveCard (a bot @-mentioning this bot) nests the body in
// content.cardContent and carries the mention as its own leading
// leaf; the leaf-aware extractor drops it so the agent gets the
// clean instruction. Other structured-text shapes use the generic
// extractor.
if strings.EqualFold(msgtype, "interactiveCard") {
text = extractInteractiveCardText(data.Content)
}
if text == "" {
if fallback := extractCallbackText(data.Content); fallback != "" {
text = fallback
}
}
}
if (text == "" && picCode == "" && !fileInfo.hasActionable()) || data.SessionWebhook == "" {
// Observability: silent drops are the #1 reason a working connector
// looks dead. Log msgtype + a payload summary so an unhandled shape
// (e.g. new-style file callback without downloadCode) shows up in
// stderr instead of being invisible.
fmt.Fprintf(os.Stderr, "[connect] 丢弃消息 msgtype=%q staffId=%s convId=%s msgId=%s content=%s (无正文/图片/可下载文件或 sessionWebhook 为空)\n",
msgtype, data.SenderStaffId, data.ConversationId, data.MsgId, summarizeContent(data.Content))
return []byte(""), nil
}
// Drop redelivered duplicates so a retried message is not replied twice.
@@ -978,11 +1031,14 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
sender = strings.TrimSpace(data.SenderStaffId)
}
shown := text
if shown == "" {
if shown == "" && picCode != "" {
shown = "[图片]"
} else if shown == "" && fileInfo.hasActionable() {
shown = "[文件: " + fileInfo.FileName + "]"
}
fmt.Fprintf(os.Stderr, "[connect] 收到 @%s: %s (convType=%s convId=%s staffId=%s msgId=%s)\n",
sender, truncateRunes(shown, 80), data.ConversationType, data.ConversationId, data.SenderStaffId, data.MsgId)
health.onPush()
// Ack-first: return now, reply asynchronously via sessionWebhook (which is
// independent of the Stream ack). Use a background context so the in-flight
// forward is not cancelled by the SDK when this callback returns.
@@ -1057,6 +1113,61 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
prompt = prompt + "\n(用户同时附了一张图片,本地路径 " + localPath + ",请结合图片内容回答。)"
}
}
if fileInfo.hasActionable() {
fileName := fileInfo.FileName
var localPath string
var derr error
if fileInfo.DownloadCode != "" {
localPath, derr = mediaCli.downloadMessageFile(context.Background(), clientID, fileInfo.DownloadCode)
if derr != nil {
fmt.Fprintf(os.Stderr, "[connect][media] 文件下载失败: %v\n", derr)
}
}
switch {
case localPath != "":
if prompt == "" {
prompt = "用户发来一个文件「" + fileName + "」(本地路径 " + localPath + "),请读取文件内容并回答。"
} else {
prompt = prompt + "\n(用户同时附了一个文件「" + fileName + "」,本地路径 " + localPath + ",请结合文件内容回答。)"
}
case fileInfo.DentryID != 0 && fileInfo.SpaceID != 0:
// API-sent file: resolve via storage API (userId→unionId,
// then getDownloadInfo). Falls back to metadata-only prompt
// if the download chain fails (e.g. missing permissions).
senderID := strings.TrimSpace(callbackData.SenderStaffId)
if unionID, uerr := mediaCli.getUserUnionID(context.Background(), senderID); uerr != nil {
fmt.Fprintf(os.Stderr, "[connect][media] userId→unionId 失败 (%s): %v\n", senderID, uerr)
} else if dp, derr := mediaCli.downloadDentryFile(context.Background(), fileInfo.SpaceID, fileInfo.DentryID, unionID, fileName); derr != nil {
fmt.Fprintf(os.Stderr, "[connect][media] 钉盘文件下载失败 spaceId=%d dentryId=%d: %v\n", fileInfo.SpaceID, fileInfo.DentryID, derr)
} else {
localPath = dp
}
if localPath != "" {
if prompt == "" {
prompt = "用户发来一个文件「" + fileName + "」(本地路径 " + localPath + "),请读取文件内容并回答。"
} else {
prompt = prompt + "\n(用户同时附了一个文件「" + fileName + "」,本地路径 " + localPath + ",请结合文件内容回答。)"
}
} else {
meta := fmt.Sprintf("文件名「%s」,dentryId=%d,spaceId=%d", fileName, fileInfo.DentryID, fileInfo.SpaceID)
if fileInfo.FileType != "" {
meta += ",类型=" + fileInfo.FileType
}
if fileInfo.FileSize > 0 {
meta += fmt.Sprintf(",大小=%d 字节", fileInfo.FileSize)
}
if prompt == "" {
prompt = "用户发来一个文件(" + meta + ")。文件下载失败,请基于文件名与用户随附的文字信息回答,必要时请用户改用客户端上传或补充文字描述。"
} else {
prompt = prompt + "\n(用户同时附了一个文件:" + meta + "。文件下载失败,请结合文件名与随附文字回答。)"
}
}
default:
if prompt == "" {
prompt = "(用户发来一个文件「" + fileName + "」,但文件下载失败了。请告知用户文件没收到,建议重新发送。)"
}
}
}
if extras.kb != nil {
prompt = extras.kb.augment(prompt)
}
@@ -1080,8 +1191,14 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
// ② agent runs;
// ③ on done: deliver the AI card with the final content;
// ④ swap the chip to "🥳Done".
// Reactions attach to the triggering message, but DingTalk rejects a
// reaction on an interactiveCard message (a bot @-mentioning this bot)
// with a 500 system.error — reactions are only supported on human
// messages. Skip the chip for those turns so we don't fire a call the
// platform always rejects; the reply itself is unaffected.
canReact := !strings.EqualFold(msgtype, "interactiveCard")
thinking := false
if cardCli != nil {
if cardCli != nil && canReact {
if terr := cardCli.markThinking(context.Background(), callbackData.ConversationId, msgID); terr != nil {
fmt.Fprintf(os.Stderr, "[connect][card] Thinking 表态失败(不影响回复): %v\n", terr)
} else {
@@ -1117,9 +1234,20 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
}
if err != nil {
fmt.Fprintf(os.Stderr, "[connect] 转发失败 (%s, 耗时 %s): %v\n", channel, time.Since(started).Round(time.Millisecond), err)
reply = fmt.Sprintf("(%s 调用失败:%v)", channel, err)
if errors.Is(err, context.DeadlineExceeded) {
// Self-recovery: drop the conversation's session so the next
// message starts fresh instead of reusing a stuck one.
if r, ok := fwd.(sessionResetter); ok {
r.resetSession(convID)
}
reply = fmt.Sprintf("(%s 回复超时,已自动重置会话,请重试。如需调整超时上限可用 --agent-timeout)", channel)
} else {
reply = fmt.Sprintf("(%s 调用失败:%v)", channel, err)
}
health.onError(err)
} else {
fmt.Fprintf(os.Stderr, "[connect] agent 已生成回复 (%s, 耗时 %s): %s\n", channel, time.Since(started).Round(time.Millisecond), truncateRunes(reply, 80))
health.onReply()
}
// Confirmation gate orchestration: if the agent's reply declared
@@ -1173,22 +1301,40 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
if !delivered {
// Fallback path: plain reply via the inbound sessionWebhook.
// Long replies go as markdown, short ones as text.
// Retry up to 3 times with exponential backoff (1s, 2s, 4s)
// to handle transient network errors (EOF, timeout).
var sendErr error
if len([]rune(reply)) > 200 {
sendErr = replier.SimpleReplyMarkdown(context.Background(), webhook, []byte(channel), []byte(reply))
} else {
sendErr = replier.SimpleReplyText(context.Background(), webhook, []byte(reply))
backoffs := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second}
for attempt := 0; attempt <= len(backoffs); attempt++ {
if len([]rune(reply)) > 200 {
sendErr = replier.SimpleReplyMarkdown(context.Background(), webhook, []byte(channel), []byte(reply))
} else {
sendErr = replier.SimpleReplyText(context.Background(), webhook, []byte(reply))
}
if sendErr == nil {
break
}
if attempt < len(backoffs) {
fmt.Fprintf(os.Stderr, "[connect] 普通消息发送失败 (%s, attempt %d/%d, msgId=%s): %v,%v 后重试\n",
channel, attempt+1, len(backoffs)+1, msgID, sendErr, backoffs[attempt])
time.Sleep(backoffs[attempt])
}
}
if sendErr != nil {
fmt.Fprintf(os.Stderr, "[connect] 普通消息发送失败 (%s, msgId=%s): %v\n", channel, msgID, sendErr)
fmt.Fprintf(os.Stderr, "[connect] 普通消息发送失败(重试耗尽) (%s, msgId=%s): %v\n", channel, msgID, sendErr)
delivered = false
} else {
fmt.Fprintf(os.Stderr, "[connect] 普通消息已发送 (%s, msgId=%s)\n", channel, msgID)
delivered = true
}
}
if thinking {
cardCli.swapThinkingToDone(context.Background(), callbackData.ConversationId, msgID)
if delivered {
cardCli.swapThinkingToDone(context.Background(), callbackData.ConversationId, msgID)
} else {
fmt.Fprintf(os.Stderr, "[connect] 回复发送失败,保留思考表态不切换完成 (%s, msgId=%s)\n", channel, msgID)
}
}
})
return []byte(""), nil
@@ -1208,6 +1354,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
return apperrors.NewInternal("stream 建连失败:" + err.Error())
}
defer cli.Close()
health.onConnected()
<-ctx.Done()
return nil
}
@@ -1231,6 +1378,16 @@ func envDurationMS(key string, def time.Duration) time.Duration {
return def
}
// applyTimeout returns a context bounded by timeout when timeout > 0, or the
// original context unchanged when timeout == 0 (no limit). The returned cancel
// func is always safe to defer.
func applyTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
if timeout > 0 {
return context.WithTimeout(ctx, timeout)
}
return ctx, func() {}
}
// truncateRunes truncates by rune so multi-byte characters are never split.
func truncateRunes(s string, max int) string {
r := []rune(s)
+23
View File
@@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)
// TestBrandReply covers the qoderwork identity rewrite using the exact replies
@@ -122,3 +123,25 @@ func TestClaudeUserSettingsEnv(t *testing.T) {
}
}
}
// TestCheckFDLimit verifies that checkFDLimit runs without panic and respects
// the envDurationMS pattern for the keepAlive default.
func TestCheckFDLimit(t *testing.T) {
// Should not panic regardless of the actual ulimit.
checkFDLimit()
}
func TestEnvDurationMS(t *testing.T) {
def := 30000 * time.Millisecond // 30s
if got := envDurationMS("DWS_CONNECT_KEEPALIVE_MS_TEST_ABSENT", def); got != def {
t.Fatalf("default keepAlive = %v, want %v", got, def)
}
t.Setenv("DWS_CONNECT_KEEPALIVE_MS_TEST", "10000")
if got := envDurationMS("DWS_CONNECT_KEEPALIVE_MS_TEST", def); got != 10*time.Second {
t.Fatalf("env override = %v, want 10s", got)
}
t.Setenv("DWS_CONNECT_KEEPALIVE_MS_TEST", "bogus")
if got := envDurationMS("DWS_CONNECT_KEEPALIVE_MS_TEST", def); got != def {
t.Fatalf("invalid env falls back to default = %v, want %v", got, def)
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ func (f *execForwarder) forwardStream(ctx context.Context, convID, text string,
if !f.canStream() || onDelta == nil {
return f.forward(ctx, convID, text)
}
ctx, cancel := context.WithTimeout(ctx, f.timeout)
ctx, cancel := applyTimeout(ctx, f.timeout)
defer cancel()
var args []string
+21 -6
View File
@@ -1611,7 +1611,7 @@ func normalizeDevAppRobotResult(content map[string]any) {
devAppMarkVersionPublishBlocked(content)
}
if localConnectReady {
steps = append(steps, devAppRobotConnectStep(clientID))
steps = append(steps, devAppRobotConnectStep(clientID, unifiedAppID))
}
case "FAIL":
lifecycle["phase"] = "failed"
@@ -1725,14 +1725,29 @@ func devAppRobotRetryStep(taskID string, reuseTaskID bool) map[string]any {
})
}
func devAppRobotConnectStep(clientID string) map[string]any {
if clientID == "" {
clientID = "<clientId>"
// devAppRobotConnectStep advertises the local-debug connect command. The
// preferred form is `--unified-app-id`, which reuses `dev app credentials get`
// to fetch clientSecret at runtime — the secret never appears in argv, so it
// stays hidden from `ps` / journald / shell history. Only when unifiedAppID is
// unavailable do we fall back to `--robot-client-id`, and even then we point
// the caller at the safe path in doneWhen instead of hardcoding a
// clientSecret placeholder into the command string.
func devAppRobotConnectStep(clientID, unifiedAppID string) map[string]any {
var command, doneWhen string
if unifiedAppID != "" {
command = fmt.Sprintf("dws dev connect --unified-app-id %s --format json", unifiedAppID)
doneWhen = "本地 Stream 建联成功,进程保持运行;密钥由 credentials get 后台取回,命令行不出现 clientSecret"
} else {
if clientID == "" {
clientID = "<clientId>"
}
command = fmt.Sprintf("dws dev connect --robot-client-id %s --format json", clientID)
doneWhen = "本地 Stream 建联成功;建议改用 --unified-app-id <uappid>,避免 clientSecret 出现在命令行被 ps 看到"
}
step := devAppNextStep(devAppStep{
ID: "connect_local",
Command: fmt.Sprintf("dws dev connect --robot-client-id %s --robot-client-secret <clientSecret-from-result> --format json", clientID),
DoneWhen: "本地 Stream 建联成功,进程保持运行;clientSecret 只使用返回值,不写入命令建议",
Command: command,
DoneWhen: doneWhen,
})
step["sensitiveFields"] = []string{"clientSecret"}
step["optional"] = true
+22 -2
View File
@@ -519,6 +519,16 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
}
}
// Security: if the caller pasted clientSecret onto argv, warn once —
// any user on the box can lift it with `ps -ef`. The safe path is
// --unified-app-id, which resolves the secret through
// `dev app credentials get` at runtime so it never touches argv.
// Emit before dry-run so both preview and real launch surface the
// warning; the warning is idempotent (one line per invocation).
if strings.HasPrefix(resolvedBy, "flag:") {
fmt.Fprintln(cmd.ErrOrStderr(), "[connect] WARNING: --robot-client-secret 出现在命令行,任何本机用户都能通过 `ps` 看到;建议改用 --unified-app-id <uappid>,由 dev app credentials get 后台取密钥。")
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, connectPreviewEnvelope(map[string]any{
"channel": channel,
@@ -535,7 +545,10 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
// connector alive 7x24. We resolve credentials/channel first (above) so
// the parent fails fast on bad input before forking, then re-exec.
if daemonMode, _ := cmd.Flags().GetBool(daemonFlag); daemonMode {
return startDaemon(cmd, daemonDirKey(clientID, unifiedAppID), clientID)
notifyStaffID := devAppStringFlag(cmd, "notify-staff-id")
profile, _ := cmd.Root().PersistentFlags().GetString("profile")
alwaysOn, _ := cmd.Flags().GetBool("alwayson")
return startDaemon(cmd, daemonDirKey(clientID, unifiedAppID), clientID, unifiedAppID, channel, notifyStaffID, strings.TrimSpace(profile), alwaysOn)
}
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] channel=%s(%s)凭证来源=%s\n", channel, detectedBy, resolvedBy)
@@ -546,7 +559,8 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
preferLegacyLeaf(cmd)
// Daemon mode (see connect_daemon.go). --daemon detaches the connector into a
// self-restarting background supervisor; status/stop are sibling subcommands.
cmd.Flags().Bool(daemonFlag, false, "守护进程模式:把连接器放到后台常驻(脱离终端、崩溃自拉起),父进程打印 pid/日志路径后退出(Windows 暂不支持)")
cmd.Flags().Bool(daemonFlag, false, "守护进程模式:把连接器放到后台运行(脱离终端),父进程打印 pid/日志路径后退出(Windows 暂不支持)")
cmd.Flags().Bool("alwayson", false, "常驻模式:worker 崩溃后自动重启(仅 --daemon 生效)")
// Internal re-exec mode flags, hidden from help.
cmd.Flags().Bool(daemonSuperviseFlag, false, "internal: run the daemon supervisor (set automatically by --daemon)")
cmd.Flags().Bool(daemonWorkerFlag, false, "internal: run a single supervised connector worker (set automatically by the supervisor)")
@@ -555,6 +569,8 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
cmd.AddCommand(
newDevAppRobotConnectStatusCommand(),
newDevAppRobotConnectStopCommand(),
newDevAppRobotConnectRestartCommand(),
newDevAppRobotConnectListCommand(runner),
)
cmd.Flags().String("channel", "auto", "渠道:auto(默认,自动探测)|openclaw|qoder|qoderwork|hermes|workbuddy|claudecode|codebuddy|codex|gemini|opencode|custom(自研/未支持的 AI,配 --agent-cmd)")
cmd.Flags().String("agent-cmd", "", "自研/未支持的 AI 工具命令(无头/一次性:问题作为最后一个参数追加,答案打到 stdout);用来接入内置渠道之外的 AI(如网易有道龙虾 LobsterAI);等价于 --channel custom + 设 DWS_AGENT_CMD;env: DWS_AGENT_CMD")
@@ -567,6 +583,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("agent-model", "", "覆盖本地 agent 模型(如 claude 的 sonnet/opus;默认用渠道内置模型,求快);env: DWS_AGENT_MODEL")
cmd.Flags().String("agent-workdir", "", "本地 agent 的运行目录(放知识文件可给机器人上下文;默认空白临时目录,求快);env: DWS_AGENT_WORKDIR")
cmd.Flags().Bool("agent-memory", true, "按会话续聊:同一群/单聊共享 agent 会话上下文(codex/opencode/qoder/qoderwork/claudecode/codebuddy/workbuddy 支持;--agent-memory=false 关闭)")
cmd.Flags().Int("agent-timeout", 0, "每次 agent 调用的超时时间(秒),0=不限制(默认);env: DWS_AGENT_TIMEOUT_MS(毫秒)")
cmd.Flags().Bool("reply-card", true, "用 AI 卡片回复(思考中→完成状态,同官方渠道体验);卡片失败自动回退普通消息;--reply-card=false 关闭")
cmd.Flags().String("card-template", "", "AI 卡片模板 ID(开发者后台·本应用·AI 卡片设置里获取;模板按应用授权,强烈建议注册自己应用的模板);env: DWS_CARD_TEMPLATE")
cmd.Flags().String("knowledge-dir", "", "答疑知识目录(.md/.txt):每条消息本地检索 top-k 片段拼进 prompt,agent 仍在空目录跑、不拖慢回复;env: DWS_KNOWLEDGE_DIR")
@@ -579,6 +596,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("role-config", "", "数字员工角色配置 YAML:用角色的主人/人设/知识源填充未显式给出的选项(显式 flag 优先);role 的 client_id 必须与本机器人一致;env: DWS_ROLE_CONFIG")
cmd.Flags().String("audit-sheet", "", "审计在线表格 ID/URL(axls):确认闸每个操作追加一行到该表格,可在钉钉随时查看;空=仅本地审计文件;env: DWS_AUDIT_SHEET")
cmd.Flags().String("audit-sheet-tab", "Sheet1", "审计表格的工作表 ID/名称(配合 --audit-sheet);env: DWS_AUDIT_SHEET_TAB")
cmd.Flags().String("notify-staff-id", "", "状态通知 staffId:机器人启动/停止/崩溃时自动发钉钉消息通知此人;env: DWS_NOTIFY_STAFF_ID")
return cmd
}
@@ -595,6 +613,7 @@ func connectAgentOptionsFromCommand(cmd *cobra.Command) connectAgentOptions {
workDir = strings.TrimSpace(os.Getenv("DWS_AGENT_WORKDIR"))
}
memory, _ := cmd.Flags().GetBool("agent-memory")
agentTimeoutSec, _ := cmd.Flags().GetInt("agent-timeout")
replyCard, _ := cmd.Flags().GetBool("reply-card")
// Env kill-switch for scripted/service runs: DWS_REPLY_CARD=0 disables
// cards regardless of the flag default.
@@ -658,6 +677,7 @@ func connectAgentOptionsFromCommand(cmd *cobra.Command) connectAgentOptions {
auditSheetTab = "Sheet1"
}
return connectAgentOptions{Model: model, WorkDir: workDir, Memory: memory,
Timeout: time.Duration(agentTimeoutSec) * time.Second,
ReplyCard: replyCard, CardTemplate: cardTemplate,
KnowledgeDir: knowledgeDir,
KnowledgeSource: knowledgeSource,

Some files were not shown because too many files have changed in this diff Show More