Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5dbefb27e | ||
|
|
1c1abf3069 | ||
|
|
043881e0e3 | ||
|
|
b63e1b4469 | ||
|
|
bba94c0092 | ||
|
|
fe4a79283c | ||
|
|
3ee5f13c62 | ||
|
|
e32fa1535c | ||
|
|
79b8eda3b6 |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -42,3 +42,5 @@ dws.zip
|
||||
# 功能测试运行产物
|
||||
results.jsonl
|
||||
test/dev_functional/results.jsonl
|
||||
.vercel
|
||||
.env*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,这是啥意思?」
|
||||
|
||||
@@ -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命令 一次性问答参数 "用户的问题"`(问题作为最后一个参数追加),把它打印出来的内容当作回复发回群里。
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() + "..."
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 重新登录。"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 重新登录"))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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 = "" })
|
||||
|
||||
@@ -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() {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+85
@@ -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
|
||||
@@ -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
|
||||
Executable
+623
@@ -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"
|
||||
@@ -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,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>`。注意:退主组织不会被拦,会静默把「主」改选为剩下第一个组织,退主前必须向用户确认 |
|
||||
|
||||
### 确认流程
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` 获取)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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 拿到:
|
||||
|
||||
@@ -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>`。退主组织不会被拦截,会静默改选新主,执行前必须向用户确认。
|
||||
@@ -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` 查官方文档,不编造原因
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user