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
93 changed files with 8528 additions and 414 deletions
+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
+3
View File
@@ -31,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
+2
View File
@@ -42,3 +42,5 @@ dws.zip
# 功能测试运行产物
results.jsonl
test/dev_functional/results.jsonl
.vercel
.env*
+57
View File
@@ -6,6 +6,63 @@ 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.
+44 -23
View File
@@ -233,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>
@@ -510,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 |
@@ -524,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` | 23 | `event` `book` `acl` `attendee` `participant` `room` `busy` `attachment` | Events CRUD + suggested times + attachments, calendar books (get / search / primary via `--id primary`), access-control list, attendee management (wukong-aligned naming, `--calendar-id` aware), meeting room booking, free-busy query |
| 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` | 102 | `base` `table` `record` `field` `view` `section` `advperm` `workflow` `dashboard` `chart` `import` `export` `attachment` `template` `form` | Full CRUD for Bases / datasheets / records / fields / views; node (section) management, advanced permission & roles, automation workflows; record upsert / share-url / history / primary-doc; view lock / duplicate / frozen-cols / row-height / fill-color-rule / card / timebar; charts & dashboards; import/export; attachments; forms; templates |
| Doc | `doc` | 29 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` `permission` `media` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), permission management, media, upload / download |
| Drive | `drive` | 17 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` `copy` `move` `rename` `search` `permission` | DingTalk drive file ops: list / search / info / download, create folders, upload (one-shot or two-phase), copy / move / rename, permission management (list / mutation / remove), doc-transfer, delete |
| Minutes | `minutes` | 31 | `list` `get` `tag` `update` `mind-graph` `speaker` `permission` `hot-word` `record` `upload` `replace-text` | List AI meeting notes (mine / shared / all), tags (list / query by tag), details (info / summary / keywords / transcription / todos / batch), title/summary updates, mind map, speaker, member permission, hot-word, recording control, upload session |
| Mail | `mail` | 33 | `mailbox` `message` `send` `draft` `folder` `template` `contact` `tag` `thread` `attachment` `user` | List mailboxes, KQL search + folder-scoped message list, read & send messages, drafts, folder CRUD, message templates (CRUD), mail contacts (CRUD), tags, threads, attachments, address-book user search |
| Sheet | `sheet` | 60 | `range` `filter` `filter-view` `cond-format` (+ dimension, float-image, dropdown, csv, merge, find/replace, write-image, …) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read / write / copy / fill / sort / style, dimension ops, filters & filter views, conditional formatting, float images, dropdowns, CSV get/put, cell merge / unmerge, find / replace, image write |
| Wiki | `wiki` | 27 | `space` `member` `node` `doc` `file` | Knowledge base management: spaces (create / get / list / search / delete), members (add / list / update / remove), node tree (create / delete / list / search / move / copy / transfer), 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 |
> **466 commands across 18 products** (after the dws-wukong alignment in 1.0.43). 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.
+38 -22
View File
@@ -233,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>
@@ -520,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,这是啥意思?」
+7 -4
View File
@@ -55,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`。
## 第四步:拉进群聊
在钉钉里打开目标群:
@@ -95,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,
}
}
+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")
+76 -1
View File
@@ -163,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) {
@@ -217,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 {
@@ -224,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,
+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)
}
}
+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
}
+87 -1
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
@@ -298,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(),
@@ -321,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)
@@ -358,6 +363,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
utilityCommands := []*cobra.Command{
newAuthCommand(patCaller),
newProfileCommand(),
newAPICommand(flags),
newSkillCommand(),
newCacheCommand(),
@@ -404,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)
}
@@ -802,6 +887,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
"completion": true,
"skill": true,
"plugin": true,
"profile": true,
"version": true,
"help": true,
"recovery": true,
@@ -828,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,
+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 ""
+196 -27
View File
@@ -162,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)
}
@@ -179,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 {
@@ -239,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.
//
@@ -604,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
@@ -640,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 {
@@ -714,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
@@ -757,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()
+1 -1
View File
@@ -751,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,
}
+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)
}
+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, newChatFileGroup(runner))
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
}
+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,
+24 -10
View File
@@ -39,10 +39,12 @@ func TestDevAppRobotConnectRegistered(t *testing.T) {
// dry-run path (which never launches the Stream connector).
func TestDevAppRobotConnectValidation(t *testing.T) {
cases := []struct {
name string
args []string
wantErr string
wantJSON []string // substrings expected in successful dry-run output
name string
args []string
wantErr string
wantJSON []string // substrings expected in successful dry-run output
wantStderr []string // substrings expected in combined stderr/stdout stream
notStderr []string // substrings that must NOT appear
}{
{
name: "no credentials and no unified-app-id",
@@ -55,14 +57,16 @@ func TestDevAppRobotConnectValidation(t *testing.T) {
wantErr: "未知渠道",
},
{
name: "explicit credentials dry-run emits plan",
args: []string{"--channel", "claudecode", "--robot-client-id", "id1", "--robot-client-secret", "sec1", "--dry-run"},
wantJSON: []string{"\"credentialSource\"", "flag:--robot-client-id/--robot-client-secret", "stream-bridge", "\"clientId\"", "\"completionState\": \"LOCAL_DEBUG_ONLY\"", "\"doesNotPublish\": true", "\"scope\": \"local_debug_only\""},
name: "explicit credentials dry-run emits plan and secret warning",
args: []string{"--channel", "claudecode", "--robot-client-id", "id1", "--robot-client-secret", "sec1", "--dry-run"},
wantJSON: []string{"\"credentialSource\"", "flag:--robot-client-id/--robot-client-secret", "stream-bridge", "\"clientId\"", "\"completionState\": \"LOCAL_DEBUG_ONLY\"", "\"doesNotPublish\": true", "\"scope\": \"local_debug_only\""},
wantStderr: []string{"[connect] WARNING", "--robot-client-secret 出现在命令行", "建议改用 --unified-app-id"},
},
{
name: "unified-app-id dry-run skips credentials get",
args: []string{"--channel", "qoderwork", "--unified-app-id", "UAID", "--dry-run"},
wantJSON: []string{"credentials get, skipped in dry-run", "\"unifiedAppId\"", "\"completionState\": \"LOCAL_DEBUG_ONLY\"", "\"doesNotPublish\": true", "\"scope\": \"local_debug_only\""},
name: "unified-app-id dry-run skips credentials get and emits no secret warning",
args: []string{"--channel", "qoderwork", "--unified-app-id", "UAID", "--dry-run"},
wantJSON: []string{"credentials get, skipped in dry-run", "\"unifiedAppId\"", "\"completionState\": \"LOCAL_DEBUG_ONLY\"", "\"doesNotPublish\": true", "\"scope\": \"local_debug_only\""},
notStderr: []string{"[connect] WARNING"},
},
}
@@ -89,6 +93,16 @@ func TestDevAppRobotConnectValidation(t *testing.T) {
t.Fatalf("output missing %q:\n%s", sub, out.String())
}
}
for _, sub := range tc.wantStderr {
if !strings.Contains(out.String(), sub) {
t.Fatalf("stderr/stdout missing %q:\n%s", sub, out.String())
}
}
for _, sub := range tc.notStderr {
if strings.Contains(out.String(), sub) {
t.Fatalf("stderr/stdout unexpectedly contains %q:\n%s", sub, out.String())
}
}
})
}
}
+5 -2
View File
@@ -1249,8 +1249,11 @@ func TestDevAppRobotResultSuccessAddsPublishAndConnectSteps(t *testing.T) {
if connect["blocking"] != false || connect["optional"] != true || connect["scope"] != "local_debug_only" {
t.Fatalf("connect step = %#v, want optional local debug non-blocking step", connect)
}
if command, _ := connect["command"].(string); !strings.Contains(command, "--robot-client-secret <clientSecret-from-result>") {
t.Fatalf("connect command = %q, want clientSecret placeholder", command)
if command, _ := connect["command"].(string); !strings.Contains(command, "--unified-app-id u-1") {
t.Fatalf("connect command = %q, want --unified-app-id form (safe: clientSecret not on argv)", command)
}
if command, _ := connect["command"].(string); strings.Contains(command, "--robot-client-secret") {
t.Fatalf("connect command = %q, must not put clientSecret on argv", command)
}
assertDevAppStepCommandsDoNotContain(t, steps, "secret-client")
}
+6 -6
View File
@@ -1907,13 +1907,13 @@ func TestChmod_agentCode_env_invalid(t *testing.T) {
// wins and env is silently ignored (no warning needed because the flag is
// the explicit, scripted intent).
func TestChmod_agentCode_flag_wins_over_env(t *testing.T) {
t.Setenv(agentCodeEnv, "envval")
t.Setenv(agentCodeEnv, "qoder")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("agentCode", "flagval")
_ = cmd.Flags().Set("agentCode", "QoderWork")
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
t.Fatalf("chmod RunE error = %v", err)
@@ -1921,11 +1921,11 @@ func TestChmod_agentCode_flag_wins_over_env(t *testing.T) {
if fake.gotTool != patBatchGrantToolName {
t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName)
}
if got := fake.gotAgentEnv; got != "flagval" {
t.Fatalf("agent env = %q, want %q (flag must win over env)", got, "flagval")
if got := fake.gotAgentEnv; got != "QoderWork" {
t.Fatalf("agent env = %q, want %q (flag must win over env)", got, "QoderWork")
}
if got := fake.gotArgs["agentCode"]; got != "flagval" {
t.Fatalf("batch agentCode = %#v, want flagval", got)
if got := fake.gotArgs["agentCode"]; got != "QoderWork" {
t.Fatalf("batch agentCode = %#v, want QoderWork", got)
}
}
+3
View File
@@ -85,6 +85,9 @@ type Hooks struct {
// --- HTTP headers ---
MergeHeaders func(base map[string]string) map[string]string
// --- EnterpriseCredential HTTP headers ---
EnterpriseCredentialHeaders func(base map[string]string) map[string]string
// --- auth ---
AuthClientID string // OAuth client ID for device-flow authorisation
AuthClientFromMCP bool // true → fetch client ID from MCP at runtime
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env sh
# connect-watchdog.sh — keep a `dws dev connect` connector alive using the
# health contract from `dws dev connect status --json`.
#
# This is the "脚本" side of the 0701 review: a small, inspectable local watchdog
# that (1) asks dws whether the connection is actually healthy — not just whether
# a process exists — and (2) relaunches it when it is down/degraded. Drop it into
# cron or launchd; it is idempotent, so running it every few minutes is safe.
#
# It consumes the machine-readable contract, so it never parses `ps` or guesses.
#
# Usage:
# connect-watchdog.sh --client-id <clientId> [--dry-run] -- <launch command...>
#
# Example (relaunch a daemon connector if it is not healthy):
# connect-watchdog.sh --client-id ding123 -- \
# dws dev connect --robot-client-id ding123 --channel opencode --daemon
#
# cron (every 5 minutes):
# */5 * * * * /path/to/connect-watchdog.sh --client-id ding123 -- \
# dws dev connect --robot-client-id ding123 --channel opencode --daemon >> ~/.dws/connect/ding123/watchdog.log 2>&1
#
# Exit codes: 0 = healthy (no action) or relaunch issued; 1 = usage error.
set -eu
DWS="${DWS_BIN:-dws}"
CLIENT_ID=""
DRY_RUN=0
while [ $# -gt 0 ]; do
case "$1" in
--client-id) CLIENT_ID="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
--) shift; break ;;
*) echo "unknown arg: $1" >&2; exit 1 ;;
esac
done
if [ -z "$CLIENT_ID" ]; then
echo "usage: connect-watchdog.sh --client-id <clientId> [--dry-run] -- <launch command...>" >&2
exit 1
fi
if [ $# -eq 0 ]; then
echo "error: missing launch command after --" >&2
exit 1
fi
ts() { date '+%Y-%m-%d %H:%M:%S'; }
# Ask dws for the health verdict. `--json` is the stable contract.
STATUS_JSON="$("$DWS" dev connect status --robot-client-id "$CLIENT_ID" --json 2>/dev/null || true)"
# Extract "state" without a hard jq dependency (fall back to jq when present).
if command -v jq >/dev/null 2>&1; then
STATE="$(printf '%s' "$STATUS_JSON" | jq -r '.state // empty' 2>/dev/null)"
else
STATE="$(printf '%s' "$STATUS_JSON" | sed -n 's/.*"state"[[:space:]]*:[[:space:]]*"\([a-z_]*\)".*/\1/p' | head -n1)"
fi
# Treat empty, "null", or any non-word value as unknown so the watchdog relaunches.
case "$STATE" in ""|null) STATE="unknown" ;; esac
case "$STATE" in
healthy)
echo "$(ts) [watchdog] $CLIENT_ID healthy — no action"
exit 0
;;
not_running|down|degraded|unknown)
echo "$(ts) [watchdog] $CLIENT_ID state=$STATE — relaunching: $*"
if [ "$DRY_RUN" -eq 1 ]; then
echo "$(ts) [watchdog] dry-run, not executing"
exit 0
fi
# For down/degraded, stop the old connector first so we do not fight the
# single-instance lock; not_running has nothing to stop (ignore failures).
if [ "$STATE" = "down" ] || [ "$STATE" = "degraded" ]; then
"$DWS" dev connect stop --robot-client-id "$CLIENT_ID" >/dev/null 2>&1 || true
fi
exec "$@"
;;
*)
echo "$(ts) [watchdog] $CLIENT_ID unexpected state=$STATE — no action" >&2
exit 0
;;
esac
+80
View File
@@ -0,0 +1,80 @@
# connect supervision recipes
Keep a `dws dev connect` connector alive across crashes, logout, and reboots —
**without** marrying one process manager. Every recipe here consumes the same
health contract, `dws dev connect status --json` (see PR #543), so they restart
on a *real* health verdict instead of guessing from the process table.
Two layers, pick per host:
| Host | Recipe | Boot persistence | Extra dependency |
|------|--------|------------------|------------------|
| anywhere (default) | `dws dev connect --daemon` | no | none (built-in) |
| macOS | `launchd.dws-connect.plist` | yes | none (OS built-in) |
| Linux | `systemd.dws-connect.service` | yes | none (OS built-in) |
| Windows | NSSM (see below) | yes | NSSM |
| Node teams | `pm2.ecosystem.config.js` | yes | Node + pm2 |
All of them run `connect-watchdog.sh`, which is the "脚本" from the 0701 review:
poll `status --json`, and relaunch when `state` is `down`/`degraded`/`not_running`.
## Why a watchdog on top of a supervisor?
`--daemon`, launchd, systemd, and pm2 all restart on **process death**. The
failure mode the review flagged — a connection that is *alive but deaf* — never
kills the process, so none of them see it. The watchdog closes that by asking
dws for the health verdict, not the OS for the pid.
## The watchdog
```sh
connect-watchdog.sh --client-id <clientId> [--dry-run] -- <launch command...>
```
- `--client-id` — the robot clientId (how the connector is keyed on disk).
- everything after `--` — the command to run when unhealthy.
- reads `dws dev connect status --robot-client-id <id> --json`, relaunches only
when needed; on `down`/`degraded` it stops the old connector first to avoid the
single-instance lock. Idempotent, safe to run every few minutes.
## macOS (launchd)
Edit `launchd.dws-connect.plist` (clientId, channel, absolute paths), then:
```sh
cp launchd.dws-connect.plist ~/Library/LaunchAgents/com.dingtalk.dws.connect.plist
launchctl load ~/Library/LaunchAgents/com.dingtalk.dws.connect.plist
```
## Linux (systemd --user)
Edit `systemd.dws-connect.service`, then:
```sh
mkdir -p ~/.config/systemd/user
cp systemd.dws-connect.service ~/.config/systemd/user/dws-connect.service
systemctl --user daemon-reload
systemctl --user enable --now dws-connect.service
loginctl enable-linger "$USER" # keep running after logout
```
## Windows (NSSM)
`--daemon` is not supported on Windows; run the foreground connector as a
service. Install NSSM, then:
```
nssm install dws-connect "C:\path\to\dws.exe" dev connect --robot-client-id <id> --channel opencode
nssm set dws-connect AppExit Default Restart
nssm start dws-connect
```
## Node teams (pm2)
Only if you already run pm2 — it is **not** a dependency of dws. It supervises
the foreground connector (not `--daemon`, to avoid double supervision):
```sh
pm2 start pm2.ecosystem.config.js
pm2 save && pm2 startup # boot persistence
```
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- macOS launchd agent: keep a dws connector alive + watchdog every 5 min.
Edit: <clientId>, --channel, and the absolute paths, then load with
launchctl (see recipes/README.md). Consumes `dws dev connect status --json`. -->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.dingtalk.dws.connect</string>
<key>ProgramArguments</key>
<array>
<string>/absolute/path/to/scripts/dev/connect-watchdog.sh</string>
<string>--client-id</string>
<string>REPLACE_CLIENT_ID</string>
<string>--</string>
<string>/usr/local/bin/dws</string>
<string>dev</string>
<string>connect</string>
<string>--robot-client-id</string>
<string>REPLACE_CLIENT_ID</string>
<string>--channel</string>
<string>opencode</string>
<string>--daemon</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>DWS_BIN</key>
<string>/usr/local/bin/dws</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>300</integer>
<key>StandardOutPath</key>
<string>REPLACE_HOME/.dws/connect/REPLACE_CLIENT_ID/watchdog.log</string>
<key>StandardErrorPath</key>
<string>REPLACE_HOME/.dws/connect/REPLACE_CLIENT_ID/watchdog.log</string>
</dict>
</plist>
@@ -0,0 +1,23 @@
// pm2 ecosystem for teams already running pm2. pm2 is NOT a dependency of dws —
// this is one optional host, not the default. It supervises the FOREGROUND
// connector (not --daemon) to avoid double supervision. pm2 restarts on process
// death; pair with connect-watchdog (a pm2 cron_restart or an OS cron) for the
// alive-but-deaf case, which only `dws dev connect status --json` can detect.
//
// pm2 start pm2.ecosystem.config.js
// pm2 save && pm2 startup // boot persistence
module.exports = {
apps: [
{
name: 'dws-connect',
script: 'dws',
args: 'dev connect --robot-client-id REPLACE_CLIENT_ID --channel opencode',
autorestart: true,
restart_delay: 5000,
max_restarts: 50,
// Optional: periodic health-driven restart. A cleaner setup runs
// connect-watchdog.sh from cron so restarts key off `status --json`.
// cron_restart: '*/30 * * * *',
},
],
}
@@ -0,0 +1,19 @@
# Linux systemd --user unit: keep a dws connector alive. systemd restarts on
# process death; connect-watchdog (run via a companion timer, or fold the poll
# into ExecStart) catches the alive-but-deaf case via `status --json`.
# Edit ExecStart (clientId, channel, absolute dws path), install per README.
[Unit]
Description=dws dev connect (DingTalk robot connector)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Environment=DWS_BIN=/usr/local/bin/dws
# Foreground connector (NOT --daemon: systemd is the supervisor here).
ExecStart=/usr/local/bin/dws dev connect --robot-client-id REPLACE_CLIENT_ID --channel opencode
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
+623
View File
@@ -0,0 +1,623 @@
#!/usr/bin/env bash
# End-to-end regression script for multi-profile / multi-organization login.
# It uses an isolated DWS_CONFIG_DIR and DWS_KEYCHAIN_DIR, seeds post-login
# token results through the production auth storage API, then verifies the real
# dws CLI command surface.
#
# Usage:
# bash scripts/dev/test-multi-profile-e2e.sh
# bash scripts/dev/test-multi-profile-e2e.sh --skip-go-tests --verbose
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RUN_GO_TESTS=1
VERBOSE=0
KEEP_WORKDIR=0
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-go-tests)
RUN_GO_TESTS=0
shift
;;
--verbose)
VERBOSE=1
shift
;;
--keep-workdir)
KEEP_WORKDIR=1
shift
;;
-h|--help)
sed -n '1,12p' "$0"
exit 0
;;
*)
echo "unknown option: $1" >&2
exit 2
;;
esac
done
mkdir -p "$ROOT/.tmp-bin"
WORKDIR="$(mktemp -d "$ROOT/.tmp-bin/multi-profile-e2e.XXXXXX")"
BIN="$WORKDIR/bin/dws"
HELPER_DIR="$WORKDIR/helper"
CONFIG_DIR="$WORKDIR/config"
KEYCHAIN_DIR="$WORKDIR/keychain"
CACHE_DIR="$WORKDIR/cache"
OUT_DIR="$WORKDIR/out"
cleanup() {
if [[ "$KEEP_WORKDIR" -eq 1 ]]; then
echo "[INFO] kept workdir: $WORKDIR"
else
rm -rf "$WORKDIR"
fi
}
trap cleanup EXIT
export DWS_CONFIG_DIR="$CONFIG_DIR"
export DWS_KEYCHAIN_DIR="$KEYCHAIN_DIR"
export DWS_DISABLE_KEYCHAIN=1
export DWS_CACHE_DIR="$CACHE_DIR"
export DWS_PERF_REPORT=
export DWS_PERF_DEBUG=
mkdir -p "$HELPER_DIR" "$CONFIG_DIR" "$KEYCHAIN_DIR" "$CACHE_DIR" "$OUT_DIR" "$(dirname "$BIN")"
log() {
printf '\n==> %s\n' "$*"
}
fail() {
echo "[FAIL] $*" >&2
exit 1
}
run() {
if [[ "$VERBOSE" -eq 1 ]]; then
"$@"
else
"$@" >/dev/null
fi
}
capture() {
local file="$1"
shift
if [[ "$VERBOSE" -eq 1 ]]; then
echo "+ $*" >&2
fi
"$@" >"$file" 2>"$file.stderr"
}
expect_contains() {
local file="$1"
local needle="$2"
if ! grep -F -- "$needle" "$file" >/dev/null; then
echo "----- $file -----" >&2
cat "$file" >&2
fail "expected $file to contain: $needle"
fi
}
expect_not_contains_line_command() {
local file="$1"
local command="$2"
if grep -E "^[[:space:]]+$command([[:space:]]|$)" "$file" >/dev/null; then
echo "----- $file -----" >&2
cat "$file" >&2
fail "did not expect command '$command' in $file"
fi
}
expect_fail() {
local needle="$1"
shift
local output
set +e
output="$("$@" 2>&1)"
local code=$?
set -e
if [[ "$code" -eq 0 ]]; then
echo "$output" >&2
fail "expected command to fail: $*"
fi
if ! grep -F -- "$needle" <<<"$output" >/dev/null; then
echo "$output" >&2
fail "expected failure output to contain: $needle"
fi
}
cat >"$HELPER_DIR/main.go" <<'GOEOF'
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
auth "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
)
type profileListResponse struct {
Success bool `json:"success"`
PrimaryProfile string `json:"primaryProfile"`
CurrentProfile string `json:"currentProfile"`
PreviousProfile string `json:"previousProfile"`
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"`
UserName string `json:"userName"`
Status string `json:"status"`
IsPrimary bool `json:"isPrimary"`
IsCurrent bool `json:"isCurrent"`
}
type authStatusResponse struct {
Success bool `json:"success"`
Authenticated bool `json:"authenticated"`
TokenValid bool `json:"token_valid"`
RefreshTokenValid bool `json:"refresh_token_valid"`
CorpID string `json:"corp_id"`
CorpName string `json:"corp_name"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
}
type multiProfileResponse struct {
Success bool `json:"success"`
MultiProfile bool `json:"multiProfile"`
Summary multiProfileSummary `json:"summary"`
Profiles []multiProfileResult `json:"profiles"`
}
type multiProfileSummary struct {
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
}
type multiProfileResult struct {
Selector string `json:"selector"`
CorpID string `json:"corpId"`
CorpName string `json:"corpName"`
OK bool `json:"ok"`
Result map[string]any `json:"result"`
}
func main() {
if len(os.Args) < 2 {
die("missing helper command")
}
configDir := os.Getenv("DWS_CONFIG_DIR")
if strings.TrimSpace(configDir) == "" {
die("DWS_CONFIG_DIR is required")
}
switch os.Args[1] {
case "seed":
needArgs(7)
data := token(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6])
must(auth.SaveTokenData(configDir, data))
case "seed-legacy":
needArgs(7)
data := token(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6])
must(auth.SaveTokenDataKeychain(data))
must(auth.WriteTokenMarker(configDir))
case "write-app-config":
needArgs(4)
must(auth.SaveAppConfig(configDir, &auth.AppConfig{
ClientID: os.Args[2],
ClientSecret: auth.PlainSecret(os.Args[3]),
}))
case "assert-app-config":
needArgs(3)
cfg, err := auth.LoadAppConfig(configDir)
must(err)
switch os.Args[2] {
case "exists":
if cfg == nil || strings.TrimSpace(cfg.ClientID) == "" {
die("expected app config to exist")
}
case "absent":
if cfg != nil {
die("expected app config to be absent, got clientID=%q", cfg.ClientID)
}
default:
die("unknown app config expectation %q", os.Args[2])
}
case "assert-profiles":
needArgs(6)
cfg, err := auth.LoadProfiles(configDir)
must(err)
wantCount := atoi(os.Args[2])
if len(cfg.Profiles) != wantCount {
die("profiles len=%d, want %d: %#v", len(cfg.Profiles), wantCount, cfg.Profiles)
}
assertEqual("primaryProfile", cfg.PrimaryProfile, emptySentinel(os.Args[3]))
assertEqual("currentProfile", cfg.CurrentProfile, emptySentinel(os.Args[4]))
assertEqual("previousProfile", cfg.PreviousProfile, emptySentinel(os.Args[5]))
assertNoSecrets(configDir)
assertProfileMetadata(cfg)
case "assert-list-json":
needArgs(7)
var resp profileListResponse
raw := readJSON(os.Args[2], &resp)
if strings.Contains(string(raw), `"name"`) {
die("profile list JSON must not expose local name: %s", string(raw))
}
if !resp.Success {
die("profile list success=false")
}
wantCount := atoi(os.Args[3])
if len(resp.Profiles) != wantCount {
die("list profiles len=%d, want %d: %#v", len(resp.Profiles), wantCount, resp.Profiles)
}
assertEqual("list primaryProfile", resp.PrimaryProfile, emptySentinel(os.Args[4]))
assertEqual("list currentProfile", resp.CurrentProfile, emptySentinel(os.Args[5]))
assertEqual("list previousProfile", resp.PreviousProfile, emptySentinel(os.Args[6]))
for _, p := range resp.Profiles {
if strings.TrimSpace(p.CorpID) == "" || strings.TrimSpace(p.CorpName) == "" {
die("profile list item missing corp identity: %#v", p)
}
if p.CorpID == resp.PrimaryProfile && !p.IsPrimary {
die("profile %s should be primary", p.CorpID)
}
if p.CorpID == resp.CurrentProfile && !p.IsCurrent {
die("profile %s should be current", p.CorpID)
}
}
case "assert-switch-json":
needArgs(5)
var resp profileUseResponse
readJSON(os.Args[2], &resp)
if !resp.Success {
die("switch JSON success=false")
}
assertEqual("switch corpId", resp.Profile.CorpID, os.Args[3])
assertEqual("switch corpName", resp.Profile.CorpName, os.Args[4])
if !resp.Profile.IsCurrent {
die("switch profile isCurrent=false")
}
case "assert-status-json":
needArgs(6)
var resp authStatusResponse
readJSON(os.Args[2], &resp)
if !resp.Success || !resp.Authenticated || !resp.TokenValid || !resp.RefreshTokenValid {
die("bad auth status response: %#v", resp)
}
assertEqual("status corpId", resp.CorpID, os.Args[3])
assertEqual("status corpName", resp.CorpName, os.Args[4])
assertEqual("status userId", resp.UserID, os.Args[5])
case "assert-multi-profile-json":
needArgs(5)
var resp multiProfileResponse
readJSON(os.Args[2], &resp)
if !resp.Success || !resp.MultiProfile {
die("bad multi-profile response: %#v", resp)
}
wantCount := atoi(os.Args[3])
if len(resp.Profiles) != wantCount {
die("multi-profile len=%d, want %d: %#v", len(resp.Profiles), wantCount, resp.Profiles)
}
if resp.Summary.Total != wantCount || resp.Summary.Succeeded != wantCount || resp.Summary.Failed != 0 {
die("bad multi-profile summary: %#v", resp.Summary)
}
wantCorpIDs := strings.Split(os.Args[4], ",")
if len(wantCorpIDs) != wantCount {
die("want corpId count=%d, want %d", len(wantCorpIDs), wantCount)
}
for i, want := range wantCorpIDs {
want = strings.TrimSpace(want)
got := resp.Profiles[i]
if !got.OK {
die("profile %d ok=false: %#v", i, got)
}
assertEqual(fmt.Sprintf("multi-profile corpId[%d]", i), got.CorpID, want)
if got.Result["_mock"] != true {
die("profile %s result is not mock payload: %#v", got.CorpID, got.Result)
}
}
case "assert-token":
needArgs(5)
data, err := loadToken(configDir, os.Args[2])
must(err)
assertEqual("token corpId", data.CorpID, os.Args[3])
assertEqual("token access", data.AccessToken, os.Args[4])
case "assert-empty-auth":
needArgs(2)
cfg, err := auth.LoadProfiles(configDir)
must(err)
if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "" || cfg.PreviousProfile != "" || len(cfg.Profiles) != 0 {
die("expected empty profiles after reset, got %#v", cfg)
}
if auth.TokenDataExistsKeychain() {
die("legacy auth-token still exists")
}
case "assert-duplicate-name-fallback":
needArgs(4)
cfg, err := auth.LoadProfiles(configDir)
must(err)
p := findProfile(cfg, os.Args[2])
if p == nil {
die("profile %q not found", os.Args[2])
}
if p.CorpName != os.Args[3] {
die("profile %s corpName=%q, want %q", p.CorpID, p.CorpName, os.Args[3])
}
if p.Name == os.Args[3] || !strings.HasPrefix(p.Name, os.Args[3]+"-") {
die("profile %s name=%q, want stable fallback prefix %q", p.CorpID, p.Name, os.Args[3]+"-")
}
default:
die("unknown helper command %q", os.Args[1])
}
}
func token(corpID, corpName, userID, userName, access string) *auth.TokenData {
return &auth.TokenData{
AccessToken: access,
RefreshToken: "refresh-" + corpID,
PersistentCode: "persistent-" + corpID,
ExpiresAt: time.Now().Add(2 * time.Hour),
RefreshExpAt: time.Now().Add(720 * time.Hour),
CorpID: corpID,
CorpName: corpName,
UserID: userID,
UserName: userName,
ClientID: "client-" + corpID,
Source: "multi-profile-e2e",
}
}
func needArgs(n int) {
if len(os.Args) != n {
die("%s: got %d args, want %d", os.Args[1], len(os.Args)-2, n-2)
}
}
func loadToken(configDir, selector string) (*auth.TokenData, error) {
if selector == "default" {
return auth.LoadTokenData(configDir)
}
return auth.LoadTokenDataForProfile(configDir, selector)
}
func readJSON(path string, dst any) []byte {
data, err := os.ReadFile(path)
must(err)
if err := json.Unmarshal(data, dst); err != nil {
die("parse %s: %v\n%s", path, err, string(data))
}
return data
}
func assertProfileMetadata(cfg *auth.ProfilesConfig) {
names := map[string]string{}
for _, p := range cfg.Profiles {
if strings.TrimSpace(p.CorpID) == "" || strings.TrimSpace(p.CorpName) == "" {
die("profile missing corp metadata: %#v", p)
}
if prev, ok := names[p.Name]; ok {
die("duplicate profile local name %q for %s and %s", p.Name, prev, p.CorpID)
}
names[p.Name] = p.CorpID
}
}
func assertNoSecrets(configDir string) {
data, err := os.ReadFile(filepath.Join(configDir, "profiles.json"))
if err != nil {
if os.IsNotExist(err) {
return
}
must(err)
}
for _, forbidden := range []string{"access_token", "refresh_token", "persistent_code", "client_secret"} {
if strings.Contains(string(data), forbidden) {
die("profiles.json contains secret field %q", forbidden)
}
}
}
func findProfile(cfg *auth.ProfilesConfig, corpID string) *auth.Profile {
for i := range cfg.Profiles {
if cfg.Profiles[i].CorpID == corpID {
return &cfg.Profiles[i]
}
}
return nil
}
func atoi(raw string) int {
var n int
if _, err := fmt.Sscanf(raw, "%d", &n); err != nil {
die("invalid integer %q", raw)
}
return n
}
func emptySentinel(s string) string {
if s == "_" {
return ""
}
return s
}
func assertEqual(label, got, want string) {
if got != want {
die("%s=%q, want %q", label, got, want)
}
}
func must(err error) {
if err != nil {
die("%v", err)
}
}
func die(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
GOEOF
cd "$ROOT"
if [[ "$RUN_GO_TESTS" -eq 1 ]]; then
log "running multi-profile Go regressions"
go test -timeout 180s -count=1 ./internal/auth ./internal/app ./test/cli
fi
log "building dws"
run go build -o "$BIN" ./cmd
helper() {
go run "$HELPER_DIR" "$@"
}
log "checking command surface"
capture "$OUT_DIR/root-help.txt" "$BIN" --help
expect_contains "$OUT_DIR/root-help.txt" "--profile"
expect_contains "$OUT_DIR/root-help.txt" "--yes"
expect_contains "$OUT_DIR/root-help.txt" "--dry-run"
expect_contains "$OUT_DIR/root-help.txt" "profile"
capture "$OUT_DIR/profile-help.txt" "$BIN" profile --help
expect_contains "$OUT_DIR/profile-help.txt" "list"
expect_contains "$OUT_DIR/profile-help.txt" "switch"
expect_contains "$OUT_DIR/profile-help.txt" "use"
expect_contains "$OUT_DIR/profile-help.txt" "--profile"
capture "$OUT_DIR/auth-login-help.txt" "$BIN" auth login --help
expect_contains "$OUT_DIR/auth-login-help.txt" "--device"
expect_contains "$OUT_DIR/auth-login-help.txt" "--token"
expect_contains "$OUT_DIR/auth-login-help.txt" "--recommend"
expect_contains "$OUT_DIR/auth-login-help.txt" "--yes"
capture "$OUT_DIR/skill-setup-help.txt" "$BIN" skill setup --help
expect_contains "$OUT_DIR/skill-setup-help.txt" "--mode"
expect_contains "$OUT_DIR/skill-setup-help.txt" "--target"
expect_contains "$OUT_DIR/skill-setup-help.txt" "--yes"
expect_contains "$OUT_DIR/skill-setup-help.txt" "--skill"
expect_contains "$OUT_DIR/skill-setup-help.txt" "--exclude"
capture "$OUT_DIR/upgrade-help.txt" "$BIN" upgrade --help
expect_contains "$OUT_DIR/upgrade-help.txt" "--dry-run"
expect_contains "$OUT_DIR/upgrade-help.txt" "--yes"
capture "$OUT_DIR/dev-connect-help.txt" "$BIN" dev connect --help
expect_contains "$OUT_DIR/dev-connect-help.txt" "--robot-client-id"
expect_contains "$OUT_DIR/dev-connect-help.txt" "--robot-client-secret"
expect_contains "$OUT_DIR/dev-connect-help.txt" "--unified-app-id"
expect_contains "$OUT_DIR/dev-connect-help.txt" "--agent-cmd"
expect_contains "$OUT_DIR/dev-connect-help.txt" "--daemon"
capture "$OUT_DIR/doc-delete-help.txt" "$BIN" doc delete --help
expect_contains "$OUT_DIR/doc-delete-help.txt" "--yes"
capture "$OUT_DIR/aitable-base-delete-help.txt" "$BIN" aitable base delete --help
expect_contains "$OUT_DIR/aitable-base-delete-help.txt" "--yes"
capture "$OUT_DIR/auth-help.txt" "$BIN" auth --help
expect_not_contains_line_command "$OUT_DIR/auth-help.txt" "switch"
log "verifying empty profile list"
capture "$OUT_DIR/list-empty.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-empty.json" 0 _ _ _
log "seeding first organization profile"
helper seed corp_alpha "Alpha Org" user_alpha "Alice Alpha" access-alpha-v1
capture "$OUT_DIR/list-alpha.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-alpha.json" 1 corp_alpha corp_alpha _
helper assert-profiles 1 corp_alpha corp_alpha _
helper assert-token default corp_alpha access-alpha-v1
helper assert-token corp_alpha corp_alpha access-alpha-v1
capture "$OUT_DIR/status-alpha-default.json" "$BIN" auth status --format json
helper assert-status-json "$OUT_DIR/status-alpha-default.json" corp_alpha "Alpha Org" user_alpha
log "seeding second organization profile"
helper seed corp_beta "Beta Org" user_beta "Bob Beta" access-beta-v1
capture "$OUT_DIR/list-alpha-beta.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-alpha-beta.json" 2 corp_alpha corp_beta corp_alpha
helper assert-profiles 2 corp_alpha corp_beta corp_alpha
helper assert-token default corp_beta access-beta-v1
helper assert-token corp_alpha corp_alpha access-alpha-v1
helper assert-token corp_beta corp_beta access-beta-v1
log "refreshing existing organization without duplicating profile"
helper seed corp_beta "Beta Org" user_beta "Bob Beta" access-beta-v2
capture "$OUT_DIR/list-beta-refresh.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-beta-refresh.json" 2 corp_alpha corp_beta corp_alpha
helper assert-profiles 2 corp_alpha corp_beta corp_alpha
helper assert-token corp_beta corp_beta access-beta-v2
log "seeding duplicate organization name and checking stable fallback"
helper seed corp_gamma "Beta Org" user_gamma "Gina Gamma" access-gamma-v1
capture "$OUT_DIR/list-duplicate-name.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-duplicate-name.json" 3 corp_alpha corp_gamma corp_beta
helper assert-profiles 3 corp_alpha corp_gamma corp_beta
helper assert-duplicate-name-fallback corp_gamma "Beta Org"
log "switching profiles and verifying legacy mirror"
capture "$OUT_DIR/switch-alpha.json" "$BIN" profile switch corp_alpha --format json
helper assert-switch-json "$OUT_DIR/switch-alpha.json" corp_alpha "Alpha Org"
helper assert-profiles 3 corp_alpha corp_alpha corp_gamma
helper assert-token default corp_alpha access-alpha-v1
capture "$OUT_DIR/switch-beta.txt" "$BIN" profile switch corp_beta --format table
expect_contains "$OUT_DIR/switch-beta.txt" "Beta Org"
expect_contains "$OUT_DIR/switch-beta.txt" "corp_beta"
helper assert-profiles 3 corp_alpha corp_beta corp_alpha
helper assert-token default corp_beta access-beta-v2
capture "$OUT_DIR/switch-previous.json" "$BIN" profile switch - --format json
helper assert-switch-json "$OUT_DIR/switch-previous.json" corp_alpha "Alpha Org"
helper assert-profiles 3 corp_alpha corp_alpha corp_beta
capture "$OUT_DIR/use-gamma.json" "$BIN" profile use corp_gamma --format json
helper assert-switch-json "$OUT_DIR/use-gamma.json" corp_gamma "Beta Org"
helper assert-profiles 3 corp_alpha corp_gamma corp_alpha
log "checking profile switch validation"
expect_fail "profile selector required" "$BIN" profile switch
expect_fail "只能指定一个组织选择器" "$BIN" profile switch corp_alpha --corpId corp_beta
expect_fail "missing_org" "$BIN" profile switch missing_org
log "checking one-shot profile override without changing current profile"
capture "$OUT_DIR/status-root-profile-alpha.json" "$BIN" --profile corp_alpha auth status --format json
helper assert-status-json "$OUT_DIR/status-root-profile-alpha.json" corp_alpha "Alpha Org" user_alpha
helper assert-profiles 3 corp_alpha corp_gamma corp_alpha
capture "$OUT_DIR/status-local-profile-beta.json" "$BIN" auth status --profile corp_beta --format json
helper assert-status-json "$OUT_DIR/status-local-profile-beta.json" corp_beta "Beta Org" user_beta
helper assert-profiles 3 corp_alpha corp_gamma corp_alpha
capture "$OUT_DIR/status-current-gamma.json" "$BIN" auth status --format json
helper assert-status-json "$OUT_DIR/status-current-gamma.json" corp_gamma "Beta Org" user_gamma
capture "$OUT_DIR/contact-multi-profile.json" "$BIN" --mock --profile corp_alpha, corp_beta contact user get-self --format json
helper assert-multi-profile-json "$OUT_DIR/contact-multi-profile.json" 2 corp_alpha,corp_beta
helper assert-profiles 3 corp_alpha corp_gamma corp_alpha
capture "$OUT_DIR/contact-multi-profile-leaf-profile.json" "$BIN" --mock contact user get-self --profile corp_alpha, corp_beta --format json
helper assert-multi-profile-json "$OUT_DIR/contact-multi-profile-leaf-profile.json" 2 corp_alpha,corp_beta
helper assert-profiles 3 corp_alpha corp_gamma corp_alpha
log "checking auth reset cleanup"
helper write-app-config client-reset secret-reset
helper assert-app-config exists
capture "$OUT_DIR/auth-reset.txt" "$BIN" auth reset
expect_contains "$OUT_DIR/auth-reset.txt" "[OK]"
helper assert-empty-auth
helper assert-app-config absent
log "checking legacy single-slot migration"
helper seed-legacy corp_legacy "Legacy Org" user_legacy "Lena Legacy" access-legacy-v1
helper assert-profiles 0 _ _ _
capture "$OUT_DIR/list-legacy-migrated.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-legacy-migrated.json" 1 corp_legacy corp_legacy _
helper assert-profiles 1 corp_legacy corp_legacy _
helper assert-token default corp_legacy access-legacy-v1
helper assert-token corp_legacy corp_legacy access-legacy-v1
log "multi-profile e2e passed"
echo "[PASS] isolated multi-profile chain completed"
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
# Copyright 2026 Alibaba Group
# Licensed under the Apache License, Version 2.0
#
# Build and install dws directly from a Git branch checkout.
#
# Usage:
# curl -fsSL https://raw.githubusercontent.com/shangguanxuan633-lab/dingtalk-workspace-cli/codex/dws-multi-profile-login/scripts/install-from-branch.sh | sh
#
# Environment variables:
# DWS_SOURCE_REPO owner/repo to clone (default: shangguanxuan633-lab/dingtalk-workspace-cli)
# DWS_SOURCE_BRANCH branch to build (default: codex/dws-multi-profile-login)
# DWS_INSTALL_DIR passed through to scripts/install.sh (default there: ~/.local/bin)
# DWS_INSTALL_NAME passed through to scripts/install.sh (default: dws)
# DWS_NO_SKILLS passed through to scripts/install.sh (set 1 to skip skills)
# DWS_KEEP_SOURCE set 1 to keep the temporary source checkout
set -eu
REPO="${DWS_SOURCE_REPO:-shangguanxuan633-lab/dingtalk-workspace-cli}"
BRANCH="${DWS_SOURCE_BRANCH:-codex/dws-multi-profile-login}"
KEEP_SOURCE="${DWS_KEEP_SOURCE:-0}"
say() {
printf ' %s\n' "$@"
}
err() {
printf ' ❌ %s\n' "$@" >&2
exit 1
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"
}
need_cmd git
need_cmd sh
tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t dws-src)"
cleanup() {
if [ "$KEEP_SOURCE" != "1" ]; then
rm -rf "$tmpdir"
else
say "Source checkout kept at: $tmpdir"
fi
}
trap cleanup EXIT INT TERM
say "Cloning dws source:"
say " repo: https://github.com/${REPO}.git"
say " branch: ${BRANCH}"
git clone --depth 1 --branch "$BRANCH" "https://github.com/${REPO}.git" "$tmpdir"
say "Building and installing from source..."
sh "$tmpdir/scripts/install.sh"
+27
View File
@@ -27,6 +27,7 @@ cli_version: ">=1.0.15"
- **脚本优先**:[scripts/](./scripts/) 下的 `python scripts/<name>.py` 已封装翻页/轮询/批量逻辑,遇到对应场景(如 AI 表格批量导入导出、AI 应用创建轮询、文档创建后写内容、钉盘目录树等)**优先调用脚本**而非手写多步命令。脚本均支持 `--dry-run` 预览、`--format json` 输出,失败时回退到手动步骤
- **业务域最佳实践优先**:文档类多步任务先读 [04-document.md](./references/best_practices/04-document.md);AI 表格读取/统计/写入/导入导出先读 [06-data-analytics.md](./references/best_practices/06-data-analytics.md)。本仓库只迁入这些业务域 best practices,不引入其它产品行动指南。
- 知识库容器只用 `dws wiki space/member`;知识库内文件/文档的浏览、搜索、读取、创建、移动、复制统一切到 `dws doc`。`workspaceId` 只能传给 `wiki --workspace`、`doc --workspace` 或 `doc search --workspace-ids`,禁止传给 `doc list --folder`,也不要使用不存在的 `--space-id`。
- 找群 / 找人 / 找数据在当前组织没命中、且 `dws profile list` 显示 ≥2 个组织时,对每个组织带一次性 `--profile <corpId>` 各搜一遍;命中即用,全部组织都没有才追问用户。禁止在当前组织搜不到就判定「不存在」或直接甩给用户选。
## 开放平台文档 RAG / 错误码排查
@@ -72,6 +73,30 @@ cli_version: ">=1.0.15"
4. **Fallback 单产品路由**:仅当行动指南未命中,且用户意图明确是单一产品单步操作时,才按「产品总览」和「意图判断决策树」选择产品,并读取对应 `references/products/*.md`。
5. **追问**:以上步骤都无法判断时,主动追问用户澄清,严禁猜测命令、flag、URL、ID 或字段名。
## 多组织处理
dws 可同时登录多个钉钉组织,一个 profile = 一个已登录组织(corp)。当前 profile 决定本次命令用哪个组织的身份(corpId / userId 按当前 profile 自动注入,不是只支持单组织)。
**触发条件(命中任一即进入本节)**:
- 显式:用户提到 切换 / 换 / 跨组织、另一个钉钉、别的公司、看登录了哪些组织、当前是哪个组织、某人 / 某群 / 某数据在别的组织
- 隐式(最常见、易漏):在当前组织读 / 搜没找到目标(群 / 人 / 数据),且 `dws profile list` 显示已登录 ≥2 个组织 —— 别急着判「不存在」,按下方跨组织铁律去其他组织找
- 需要跨多个组织汇总 / 对比数据
- 用户问认证状态 / 登录了哪些组织 / 主组织是哪个
**不触发**:只登录 1 个组织时,按当前组织正常处理,不带 `--profile`,不进本节。
命令:
- `dws profile list` — 列出已登录组织(主 / 当前标记、状态、有效期),只读元数据
- `dws profile switch <名称|corpId|->` — 持久切换当前组织;`-` 切回上一个;无参数在交互终端弹选择器(非交互须显式传参)。`dws profile use` 是其别名
- 全局 `--profile <名称|corpId>` — 单次指定本命令用哪个组织,一次性、不改当前组织
- `dws auth login` — 再登一个组织即新增 profile(自动从授权账号取 corpId / corpName);同组织重复 login = 刷新
- `dws auth status [--profile <名称>]` — 查看认证状态
多组织数据聚合步骤:`dws profile list` 拿到所有已登录组织,对每个组织带 `--profile <corpId>` 各取一次数,合并并标注来源组织;某组织失败则标「该组织暂不可用」并继续返回其余。
安全护栏:
- 只有 `dws profile list` 显示 ≥2 个组织才启用上面的跨组织逻辑;单组织直接按当前组织走,不带 `--profile`。
- 自动跨组织只对「读 / 搜」。写 / 发 / 删 / 撤回等操作默认只在当前组织做;确需带 `--profile` 跨组织写时,必须先与用户确认目标组织。
- 持久切换 `dws profile switch`(改默认组织)按写操作对待:未经用户明确要求不得执行。跨组织找数一律用一次性 `--profile`,不改当前组织。
## 行动指南(优先匹配)
> 将用户意图与下表做**语义比对**,不要求字面包含关键词。命中后必须读取该行动指南文件,并按其中固定路线执行;多个场景同时命中时,按下方「消歧规则」选择。
@@ -112,6 +137,7 @@ cli_version: ">=1.0.15"
用户提到"在线电子表格/钉钉表格/axls/工作表/单元格读写/合并单元格/筛选视图/导出 xlsx" → `sheet`
用户提到"待办/TODO/任务提醒/循环待办" → `todo`
用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki`
用户提到"切换组织/换组织/跨组织/另一个钉钉/别的公司/多组织/看所有组织/profile/登录了哪些组织" → `profile`(见「多组织 / profile」节)
关键区分: **dev(创建/配置/建联机器人)** vs **chat(查询/发消息已有机器人)**。`dws chat bot search/find` 只查询机器人;**建号**(创建钉钉智能体机器人)走 `dws dev app robot submit`;**建联**(把机器人接到本地 agent 的 Stream)走 `dws dev connect`。凡是"创建机器人""建机器人""接入 agent""建联"一律路由到 `dev`,禁止走 `chat`。
关键区分: aitable(数据表格) vs todo(待办任务)
@@ -149,6 +175,7 @@ cli_version: ">=1.0.15"
| `oa` | `approval reject` | 拒绝待审批(需加明确理由) |
| `todo` | `task delete` | 删除待办 |
| `minutes` | `replace-text` | 全文批量替换转写与摘要 |
| `auth` | `logout` | **默认退出所有已登录组织**;只退一个加 `--profile <名称\|corpId>`。注意:退主组织不会被拦,会静默把「主」改选为剩下第一个组织,退主前必须向用户确认 |
### 确认流程
```
+1 -1
View File
@@ -316,7 +316,7 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
```bash
# 第一步:创建任务(按 scope 传必要参数)
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --format excel --timeout-ms 1000
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --export-format excel --timeout-ms 1000
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000
+9 -1
View File
@@ -388,6 +388,8 @@ Flags:
**重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。**
**`--ai-tag` 默认开(默认 true)**:dws 发送的消息默认带「通过AI发送」角标,正常发无需特意加;仅当用户要求按本人发、不带角标时传 `--ai-tag=false`。仅 send / reply 支持。
--group 指定群聊 openConversationId 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。纯文本/Markdown 单聊传 --user 时直接走 userId 发送能力,不需要先手动查询 openDingTalkId。推荐使用 --text flag 传递消息内容(也支持位置参数)。可选 --title 作为消息标题。
若用户只提供了数字群号而非 openConversationId,需先调用 `chat group get-by-group-id` 将群号转为 openConversationId,再传入 --group。
--群聊时可选 --at-all @所有人,或 --at-open-dingtalk-ids 指定成员(仅群聊时生效)。
@@ -411,6 +413,10 @@ Example:
dws chat message send --group <openconversation_id> --text "hello" --uuid "unique-id-123"
dws chat message send --group <openconversation_id> --at-all "@all 请大家注意"
dws chat message send --group <openconversation_id> --at-open-dingtalk-ids openDingTalkId1,openDingTalkId2 "<@openDingTalkId1> <@openDingTalkId2> 请查收"
# 默认即带「通过AI发送」角标,无需特意加 --ai-tag
dws chat message send --user <userId> --text "已处理好了"
# 仅当用户要求按本人发送、不带角标时
dws chat message send --user <userId> --text "已处理好了" --ai-tag=false
# 发送图片
dws chat message send --group <openconversation_id> --msg-type image --media-id <mediaId>
# 发送文件(音频/视频/文档等非图片文件统一走钉盘上传)
@@ -426,6 +432,7 @@ Flags:
--title string 消息标题(可选,默认「消息」)
--at-all @所有人(仅群聊时生效,可选,默认 false)
--at-open-dingtalk-ids string @指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效,可选)
--ai-tag 标记为「通过AI发送」角标,默认 true(默认带上);传 --ai-tag=false 关闭(按本人发送)
--media-id string 图片 mediaId(dt_media_upload 上传后用 `python scripts/extract_media_id.py <URL>` 提取,仅 msgType=image)
--msg-type string 消息类型: image/file(image 用 mediaId,file 用钉盘上传)
--dentry-id int64 钉盘文件 dentryId(msgType=file 时必填,通过 drive info 获取)
@@ -442,6 +449,7 @@ Flags:
- 纯文本/Markdown 单聊发送时 `--user` 和 `--open-dingtalk-id` 都可用;传 `--user` 时直接走 userId 发送能力
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- --at-all 和 --at-open-dingtalk-ids 仅在 --group 群聊时生效,单聊时无效;当设置--at-all时,消息内容中一定要包含对应的占位符@all;当设置--at-open-dingtalk-ids openDingTalkId1,openDingTalkId2时,消息内容中一定要包含对应格式的占位符<@openDingTalkId1> <@openDingTalkId2>
- **@ 群内机器人**:务必使用 `dws chat group bots --group <openConversationId>` 返回的 openDingtalkId(群级别 ID,与全局搜索结果不同);全局 `chat bot find` 返回的 ID 无法正确 @ 机器人
- **换行符**:消息内容按 Markdown 渲染,换行有两层要求,缺一不可:
1. 必须使用**真实换行符**(Unicode `U+000A`),而非字面量字符串 `\n`(反斜杠 + 字母 n)。程序或大模型构造参数时,须确保已正确反转义;否则全部内容会渲染在同一行
2. Markdown 规范下**单个换行不产生换行效果**。需要换行时请使用:段落分隔(连续两个真实换行符 `\n\n`)、行尾两个空格 + 真实换行符(硬换行 `<br>`),或直接写 HTML 的 `<br>` 标签
@@ -1517,7 +1525,7 @@ Flags:
- `chat message reply` 引用回复消息(**单聊/群聊均可**),需传 --conversation-id(openConversationId,单聊与群聊使用同一字段)、--ref-msg-id(被引用消息 openMessageId)、--ref-sender(被引用消息发送者 openDingTalkId)、--text(回复内容);目前回复类型仅支持 text
- `chat message forward` 转发单条消息(**源/目标会话均支持单聊/群聊**,常见组合:群→群、群→单、单→群、单→单),需传 --src-conversation-id(源会话 openConversationId)、--msg-id(源消息 openMessageId)、--dest-conversation-id(目标会话 openConversationId)
- `chat set-top` 设置/取消会话置顶(**单聊/群聊均可**),需传 --conversation-id(openConversationId,单聊与群聊使用同一字段),默认置顶,传 --off 取消
- `chat message reply` 以当前用户身份引用回复,与 `chat message send` 的用户身份发送语义一致
- `chat message reply` 以当前用户身份引用回复,与 `chat message send` 的用户身份发送语义一致;**同样支持 `--ai-tag`(默认 true,默认带「通过AI发送」角标,传 `--ai-tag=false` 关闭)**(详见上文 send 段的「AI 代发标记」规则)
- **如何获取 openConversationId**(如果上层已有则直接使用,不必再查):
- 群聊:`dws chat search --query "群名"`
- 单聊:`dws chat conversation-info --user <userId>` 或 `dws chat conversation-info --open-dingtalk-id <openDingTalkId>`(人员信息可通过 `dws aisearch person --keyword "姓名" --dimension name` 获取)
+2
View File
@@ -23,6 +23,8 @@ metadata:
> 命令参考:[aisearch.md](references/aisearch.md)。
> 跨组织:当前组织搜不到人时,别判定「查无此人」——先 `dws profile list` 看有哪些已登录组织,再对每个组织带 `--profile <corpId>` 各搜一遍,全无才追问用户。详见 `dingtalk-profile` skill。
## 开放平台文档 RAG / 错误码排查
- 任何产品执行中,只要用户问开放平台 API、接口参数、字段含义、权限点、回调、SDK、配额、错误码,或命令返回上游 OpenAPI/SDK 错误,必须先用 `dws devdoc article search --query "<关键词>" --format json` 做官方文档 RAG。
@@ -316,7 +316,7 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
```bash
# 第一步:创建任务(按 scope 传必要参数)
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --format excel --timeout-ms 1000
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --export-format excel --timeout-ms 1000
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000
+2
View File
@@ -23,6 +23,8 @@ metadata:
> 命令参考:[chat.md](references/chat.md);表情:[chat-emoji-list.md](references/chat-emoji-list.md);剧本:[01-messaging.md](references/01-messaging.md)。
> 跨组织:当前组织搜不到群 / 单聊时,别判定「不存在」——先 `dws profile list` 看有哪些已登录组织,再对每个组织带 `--profile <corpId>` 各搜一遍,全无才追问用户。详见 `dingtalk-profile` skill。
## 开放平台文档 RAG / 错误码排查
- 任何产品执行中,只要用户问开放平台 API、接口参数、字段含义、权限点、回调、SDK、配额、错误码,或命令返回上游 OpenAPI/SDK 错误,必须先用 `dws devdoc article search --query "<关键词>" --format json` 做官方文档 RAG。
@@ -444,6 +444,7 @@ Flags:
- 纯文本/Markdown 单聊发送时 `--user` 和 `--open-dingtalk-id` 都可用;传 `--user` 时直接走 userId 发送能力
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- --at-all 和 --at-open-dingtalk-ids 仅在 --group 群聊时生效,单聊时无效;当设置--at-all时,消息内容中一定要包含对应的占位符@all;当设置--at-open-dingtalk-ids openDingTalkId1,openDingTalkId2时,消息内容中一定要包含对应格式的占位符<@openDingTalkId1> <@openDingTalkId2>
- **@ 群内机器人**:务必使用 `dws chat group bots --group <openConversationId>` 返回的 openDingtalkId(群级别 ID,与全局搜索结果不同);全局 `chat bot find` 返回的 ID 无法正确 @ 机器人
- **换行符**:消息内容按 Markdown 渲染,换行有两层要求,缺一不可:
1. 必须使用**真实换行符**(Unicode `U+000A`),而非字面量字符串 `\n`(反斜杠 + 字母 n)。程序或大模型构造参数时,须确保已正确反转义;否则全部内容会渲染在同一行
2. Markdown 规范下**单个换行不产生换行效果**。需要换行时请使用:段落分隔(连续两个真实换行符 `\n\n`)、行尾两个空格 + 真实换行符(硬换行 `<br>`),或直接写 HTML 的 `<br>` 标签
@@ -532,6 +533,7 @@ Flags:
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- --at-user-ids 仅在 --group 群聊时生效,单聊时无效;设置时 --text 中需包含 @userId 对应文本
- --at-open-dingtalk-ids 仅在 --group 群聊时生效,单聊时无效;设置时 --text 中需包含 @openDingtalkId 对应文本
- **@ 群内机器人**:务必使用 `dws chat group bots --group <openConversationId>` 返回的 openDingtalkId(群级别 ID,与全局搜索结果不同);全局 `chat bot find` 返回的 ID 无法正确 @ 机器人
- --at-all @所有人,仅群聊时生效;只需带上 --at-all flag 即可,服务端会自动处理
- userId 获取方式:`dws contact user search --query "姓名"` 搜人获取 userId
- **换行符**:--text 按 Markdown 渲染,换行规则同 `chat message send`:
+2
View File
@@ -23,6 +23,8 @@ metadata:
> 命令参考:[contact.md](references/contact.md);剧本:[08-directory.md](references/08-directory.md)。
> 跨组织:当前组织查不到人时,别判定「查无此人」——先 `dws profile list` 看有哪些已登录组织,再对每个组织带 `--profile <corpId>` 各查一遍,全无才追问用户。详见 `dingtalk-profile` skill。
## 开放平台文档 RAG / 错误码排查
- 任何产品执行中,只要用户问开放平台 API、接口参数、字段含义、权限点、回调、SDK、配额、错误码,或命令返回上游 OpenAPI/SDK 错误,必须先用 `dws devdoc article search --query "<关键词>" --format json` 做官方文档 RAG。
@@ -27,6 +27,17 @@ dev 的端到端任务都是「定位应用,改容器某节点,按审批需
5. 若走无绑定的 `robot submit/result`,只有结果返回明确 `unifiedAppId` 才能继续版本发布
6. 完成态与缺 `unifiedAppId`、`SELECT_APPROVER` 等门禁判定见 [SKILL.md](../SKILL.md)「核心规则」:建联成功 + 版本进入 `RELEASE`/`AUDIT`/`UNDER_REVIEW` 才算完成
## 重启守护进程连接器(不存密钥)
守护进程被 stop / kill / 崩溃后,通过持久化的 `unifiedAppId` 重新拉取密钥并重启,无需本地保存 AppSecret。
1. `dev connect --daemon --unified-app-id <id> --channel <channel>` 首次启动(`unifiedAppId` 和 `channel` 会写入 `~/.dws/connect/<key>/daemon.pid`)
2. `dev connect restart --unified-app-id <id>` 重启:自动 stop 旧进程 → 从 dev 平台拉取 AppKey/Secret → 重新建联
3. `dev connect status --unified-app-id <id>` 确认恢复 `healthy`
4. 若 daemon.pid 未持久化 `unifiedAppId`(如用 `--robot-client-id` 直接启动的),restart 会提示改用 `--unified-app-id` 启动
注意:密钥不落盘,每次 restart 动态从开发者平台获取;`daemon.pid` 只存 `unifiedAppId`、`channel`、`clientId`(公开值)。
## 上传图片拿 mediaId(应用图标 / 机器人图标)
应用图标、机器人图标都靠 mediaId 指定,但 dev 命令集不含上传——mediaId 要调钉钉 OpenAPI 拿到:
+48
View File
@@ -0,0 +1,48 @@
---
name: dingtalk-profile
description: 钉钉多组织 / profile 管理与跨组织取数。Use when 用户说 切换组织/换组织/跨组织/另一个钉钉/别的公司/多组织/看登录了哪些组织/profile,或在当前组织找不到群/人/数据需要去其他组织找。命令前缀:dws profile / dws auth / 全局 --profile。
cli_version: ">=1.0.40"
metadata:
category: product
stability: experimental
requires:
bins:
- dws
---
# 钉钉多组织 / profile Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准;接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。
> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH.
<!-- SAFETY_PREAMBLE_INJECT -->
dws 可同时登录多个钉钉组织,一个 profile = 一个已登录组织(corp)。当前 profile 决定本次命令用哪个组织的身份(corpId / userId 按当前 profile 自动注入)。
## 触发条件(命中任一即用本 skill)
- 显式:用户提到 切换 / 换 / 跨组织、另一个钉钉、别的公司、看登录了哪些组织、当前是哪个组织、某人 / 某群 / 某数据在别的组织
- 隐式(最常见、易漏):在当前组织读 / 搜没找到目标(群 / 人 / 数据),且 `dws profile list` 显示已登录 ≥2 个组织 —— 别急着判「不存在」,按下方跨组织铁律去其他组织找
- 需要跨多个组织汇总 / 对比数据
- 用户问认证状态 / 登录了哪些组织 / 主组织是哪个
**不触发**:只登录 1 个组织时,按当前组织正常处理,不带 `--profile`。
## 命令
- `dws profile list` — 列出已登录组织(主 / 当前标记、状态、有效期),只读元数据
- `dws profile switch <名称|corpId|->` — 持久切换当前组织;`-` 切回上一个;无参数在交互终端弹选择器(非交互须显式传参)。`dws profile use` 是别名
- 全局 `--profile <名称|corpId>` — 单次指定本命令用哪个组织,一次性、不改当前组织
- `dws auth login` — 再登一个组织即新增 profile(自动从授权账号取 corpId / corpName);同组织重复 login = 刷新
- `dws auth status [--profile <名称>]` — 查看认证状态
## 跨组织铁律(必须执行,不得跳过)
「找群 / 找人 / 找数据」(chat search、aisearch / contact、doc / wiki 搜索等读 / 搜场景)在当前组织没命中、且 `dws profile list` 显示 ≥2 个组织时,对每个组织带一次性 `--profile <corpId>` 各搜一遍;命中即用,全部组织都没有才追问用户。禁止在当前组织搜不到就判定「不存在」或直接甩给用户选。
## 跨组织聚合(agent 编排,无内置 --all-orgs)
① `dws profile list` 拿到所有已登录组织 → ② 对每个组织带 `--profile <corpId>` 各取一次数 → ③ 合并并标注来源组织;某组织失败则标「该组织暂不可用」并继续返回其余。
## 安全护栏(务必遵守)
- 只有 `dws profile list` 显示 ≥2 个组织才启用跨组织逻辑;单组织直接按当前组织走,不带 `--profile`。
- 自动跨组织只对「读 / 搜」。写 / 发 / 删 / 撤回等操作默认只在当前组织做;确需带 `--profile` 跨组织写时,必须先与用户确认目标组织。
- 持久切换 `dws profile switch`(改默认组织)按写操作对待:未经用户明确要求不得执行。跨组织找数一律用一次性 `--profile`,不改当前组织。
- `dws auth logout` 默认退出所有已登录组织;只退一个加 `--profile <名称|corpId>`。退主组织不会被拦截,会静默改选新主,执行前必须向用户确认。
+39
View File
@@ -0,0 +1,39 @@
---
name: dws-shared
description: dws 多 skill 模式的公共参考——认证、全局参数、多组织 / --profile 规则、安全底线。所有 dingtalk-* 子 skill 执行前先读本 skill。命令前缀:dws。
cli_version: ">=1.0.40"
metadata:
category: productivity
stability: experimental
requires:
bins:
- dws
---
# DWS 公共参考(dws-shared)
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。
每个 dingtalk-* 子 skill 都把本 skill 列为 PREREQUISITE:执行任何产品命令前先读这里的认证、全局参数与多组织规则。`dws` 必须在 PATH 上。
## 认证
- `dws auth login`(新登一个组织即新增 profile);`--device` 无头 / SSH 登录;`--recommend` 无交互批量授权
- `dws auth status [--profile <名称|corpId>]` 查看认证状态
## 全局参数
- 所有命令加 `--format json` 取可解析输出
- 全局 `--profile <名称|corpId>`:单次指定本命令用哪个组织,一次性、不改默认组织
- 危险 / 写 / 删操作执行前先向用户确认
## 多组织 / --profile(关键规则)
dws 可同时登录多个钉钉组织,一个 profile = 一个已登录组织(corp)。当前 profile 决定本次命令用哪个组织的身份(corpId / userId 自动注入)。
- **跨组织铁律**:任何「找群 / 找人 / 找数据」(如 chat / aisearch / contact / doc / wiki / aitable / sheet / minutes / mail / report / todo / calendar / oa 的搜索、列表、查询)在当前组织没命中、且 `dws profile list` 显示 ≥2 个组织时,对每个组织带一次性 `--profile <corpId>` 各搜一遍;命中即用,全部组织都没有才追问用户。禁止在当前组织搜不到就判定「不存在」或直接甩给用户选。
- **单组织**:`dws profile list` 只有 1 个组织时,按当前组织正常处理,不带 `--profile`。
- **安全护栏**:自动跨组织只对「读 / 搜」;写 / 发 / 删 / 撤回等操作默认只在当前组织做,确需带 `--profile` 跨组织写时先与用户确认目标组织;持久切换 `dws profile switch`(改默认组织)属写操作,未经用户明确要求不得执行。
- 完整命令与跨组织聚合见 `dingtalk-profile` skill。
## 错误处理
- `unknown command` / `unknown flag`:先跑 `dws <path> --help` 查证再修正一次,别把自然语言当命令 / flag
- 认证失败 / token 过期:提示用户 `dws auth login` 重新登录
- 业务错误码 / 接口语义:用 `dws devdoc article search --query "<关键词>" --format json` 查官方文档,不编造原因
+5
View File
@@ -7,6 +7,7 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/spf13/cobra"
)
@@ -92,6 +93,10 @@ func getCapture(t *testing.T) *mcpCallCapture {
func setupTestDeps(t *testing.T, _ string) *mcpCallCapture {
t.Helper()
authpkg.SetRuntimeProfile("")
t.Cleanup(func() {
authpkg.SetRuntimeProfile("")
})
cap := &mcpCallCapture{}
linkCapture(t, cap)
return cap