Compare commits

...
Author SHA1 Message Date
Dennis 69911543c3 Merge remote-tracking branch 'origin/main' into codex/fix-text-input-bounds 2026-08-12 00:58:25 +08:00
github-actions[bot] b58b8c51bf Merge pull request #955 from DingTalk-Real-AI/codex/fix-eval-dispatch-403
fix(ci): restore eval dispatch PR comments
2026-08-12 00:52:54 +08:00
Dennis a7678472ab test(localio): scope path replacement to unix 2026-08-12 00:40:36 +08:00
Dennis f413db06be fix(localio): reject special files before open 2026-08-12 00:32:56 +08:00
Dennis 3d67d83110 test(localio): isolate input boundary e2e 2026-08-12 00:28:43 +08:00
Dennis df088573fb fix(localio): bound all text input paths 2026-08-11 23:58:11 +08:00
chichuan a0c64e5ef4 fix(ci): restore eval dispatch PR comments 2026-08-11 23:57:12 +08:00
github-actions[bot] 7a140e59c3 Merge pull request #946 from DingTalk-Real-AI/codex/minutes-shortcuts
feat(minutes): align and expand shortcut workflows
2026-08-11 23:20:21 +08:00
Dennis 38832448d2 Merge remote-tracking branch 'origin/main' into codex/minutes-shortcuts 2026-08-11 22:53:51 +08:00
Dennis 3d4e43f4fc fix(minutes): align search scope enums 2026-08-11 22:49:27 +08:00
github-actions[bot] 155ce984c9 Merge pull request #916 from gtezg30062/feat/pull_knowledge_base_dynamic_1
Feat/pull knowledge base dynamic 1
2026-08-11 14:21:30 +00:00
john 4b93a1cb28 Merge branch 'main' into feat/pull_knowledge_base_dynamic_1 2026-08-11 22:05:50 +08:00
github-actions[bot] 1d384b9189 Merge pull request #952 from DingTalk-Real-AI/feat/eval-devix-poll
feat(eval): 用可验证轮询中继替代受限网络直连
2026-08-11 21:51:30 +08:00
chichuan 025287873d Merge remote-tracking branch 'origin/main' into feat/eval-devix-poll 2026-08-11 21:33:26 +08:00
chichuan 6ddda6f1bf fix(eval): bind dispatch markers to workflow artifacts
Bind each accepted marker to the exact workflow run attempt, immutable artifact, source comment, and current PR head so a historical successful run cannot authorize a different payload.
2026-08-11 21:33:10 +08:00
github-actions[bot] dde5049454 Merge pull request #911 from Anonymity-0/feat/t07-chat-response-envelope
feat(chat): 统一 typed 与 shortcut 消息响应契约
2026-08-11 12:42:30 +00:00
Dennis a3f5a83527 Merge remote-tracking branch 'origin/main' into codex/minutes-shortcuts 2026-08-11 20:22:35 +08:00
Anonymity-0 5d7a66d4a3 Merge branch 'main' into feat/t07-chat-response-envelope 2026-08-11 20:22:20 +08:00
chichuan aebb75371b Merge branch 'main' into feat/eval-devix-poll 2026-08-11 20:17:55 +08:00
chichuanandClaude Opus 4.6 0a0634cfc2 fix(eval): harden extract_payload against non-dict JSON and invalid field types
Address P1 finding: extract_payload now strictly requires the parsed JSON
to be a dict, and validates each field's type and format:
- pr_number: string of digits
- pr_head_sha: 40-char lowercase hex string
- products: alphanumeric with commas/dots/hyphens/underscores only
- run_id: string of digits
- cases_ref: string (may be empty)

validate_run_id also guards against non-string input.

Added tests for: integer/array/string/null JSON, numeric field types,
invalid SHA format, injection in products, missing required fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-11 20:17:27 +08:00
github-actions[bot] 46aa0fe16d Merge pull request #944 from xlb1130/fix/85313115-chat-catalog-tools
fix(chat): register missing typed catalog tools
2026-08-11 20:11:26 +08:00
Anonymity-0 78165393e0 Merge branch 'main' into feat/t07-chat-response-envelope 2026-08-11 20:10:06 +08:00
Dennis 931af6af59 chore(pr): keep evidence out of merge tree 2026-08-11 19:51:57 +08:00
chichuanandClaude Opus 4.6 f6a4e0d5ad fix(eval): replace sed with bash string concat to satisfy shellcheck SC2001
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-11 19:51:32 +08:00
Dennis 44311d0160 docs(pr): publish minutes agent e2e evidence 2026-08-11 19:51:08 +08:00
长真 fb44601f21 fix(chat): restrict audit join typed enum 2026-08-11 19:48:24 +08:00
chichuanandClaude Opus 4.6 83c64d31dd security(eval): add anti-forgery validation for eval-dispatch comments
Address P1 lint finding: structured eval-dispatch comments could be
forged by unauthorized users. Add three-layer consumer-side validation:

1. comment.user.login == 'github-actions[bot]' (platform-enforced identity)
2. comment.performed_via_github_app.slug == 'github-actions' (App signature)
3. payload.run_id verified against actual successful workflow run via API

Also adds:
- eval_poll_validate.py: consumer validation module (in-repo, auditable)
- test_eval_poll_validate.py: unit tests proving forged comments are rejected
- Go security contract test updated to assert run_id and validate reference

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-11 19:47:52 +08:00
Dennis f81d09fb95 fix(localio): pin verified upload file across retries 2026-08-11 19:45:06 +08:00
chichuanandClaude Opus 4.6 e437cf4bbb feat(eval): replace direct internal API call with structured comment for Devix polling
The GitHub Actions runner cannot reach internal Aone CI API (structural
network isolation). Replace the curl-to-internal step with a structured
HTML comment (<!-- eval-dispatch: {...} -->) that an internal Devix
polling service picks up every 3 minutes to trigger the Aone CI pipeline.

This eliminates the EVAL_TRIGGER_URL/EVAL_TRIGGER_TOKEN secrets dependency
from the GitHub side — those can be removed once verified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-11 19:40:48 +08:00
Dennis cd1ba34d96 Merge remote-tracking branch 'origin/main' into codex/minutes-shortcuts 2026-08-11 19:30:15 +08:00
Dennis 2cc410db6c docs: remove shortcut analysis artifacts 2026-08-11 19:09:30 +08:00
长真 495a3b256f Merge remote-tracking branch 'origin/fix/85313115-chat-catalog-tools' into fix/85313115-chat-catalog-tools 2026-08-11 19:03:09 +08:00
长真 4210334f55 fix(chat): preserve yes shorthand on guarded writes 2026-08-11 19:00:45 +08:00
Dennis 06ec207d17 fix(minutes): harden end-to-end failure handling 2026-08-11 18:54:24 +08:00
xlb1130 30caba5dcb Merge branch 'main' into fix/85313115-chat-catalog-tools 2026-08-11 18:51:33 +08:00
长真 76316ef5f0 Merge remote-tracking branch 'origin/fix/85313115-chat-catalog-tools' into fix/85313115-chat-catalog-tools 2026-08-11 18:48:18 +08:00
长真 f5b1c2659f fix(chat): enforce confirmation for chat write tools 2026-08-11 18:47:30 +08:00
github-actions[bot] b4f0053bbe Merge pull request #924 from typefield/feat/unified-command-framework-core
feat: add unified result framework with dingtalk-dev pilot
2026-08-11 18:34:15 +08:00
玉澜 3593818a46 Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 18:15:58 +08:00
github-actions[bot] 21bbf42ca7 chore: update beta formula for v1.0.58-beta.3 [skip ci] 2026-08-11 10:06:41 +00:00
玉澜 edf1e58141 Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 17:59:38 +08:00
xlb1130 bd94c63de8 Merge branch 'main' into fix/85313115-chat-catalog-tools 2026-08-11 17:50:56 +08:00
chichuan 43b1936b65 Merge pull request #950 from DingTalk-Real-AI/codex/changelog-v1.0.58-beta.3
docs: seal v1.0.58-beta.3 changelog
2026-08-11 17:50:03 +08:00
玉澜 9d8806927f Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 17:48:35 +08:00
chichuan dbe47d58fb docs: seal v1.0.58-beta.3 changelog 2026-08-11 17:45:44 +08:00
xlb1130 8c2c94e0f1 Merge branch 'main' into fix/85313115-chat-catalog-tools 2026-08-11 17:40:29 +08:00
玉澜 b7b78f0c16 fix(dev): keep recovery commands behind confirmation 2026-08-11 17:36:59 +08:00
github-actions[bot] ec7593dabb Merge pull request #936 from wxianfeng/feature/aone85277391-event-runtime-token-handoff
fix(event): securely hand off runtime token to detached bus
2026-08-11 09:32:24 +00:00
chichuan 1df4cc95a6 Merge branch 'main' into feature/aone85277391-event-runtime-token-handoff 2026-08-11 17:15:15 +08:00
Dennis 66468c703f fix(minutes): preserve upload recovery and schema compatibility 2026-08-11 17:06:22 +08:00
Dennis 74ef426064 Merge remote-tracking branch 'origin/main' into codex/minutes-shortcuts 2026-08-11 16:55:06 +08:00
玉澜 5ce391b49b Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 16:41:42 +08:00
xlb1130 4a14f4b1e3 Merge branch 'main' into fix/85313115-chat-catalog-tools 2026-08-11 16:40:45 +08:00
玉澜 451a6fffe7 Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core
# Conflicts:
#	internal/errors/errors.go
#	internal/errors/errors_test.go
2026-08-11 16:40:15 +08:00
github-actions[bot] d052c104d9 Merge pull request #948 from cywan1998/docs/sync-calendar-skill-mono-multi
docs(skills): sync calendar reference between mono and multi layouts
2026-08-11 08:39:55 +00:00
Anonymity-0 e4e653d3b3 Merge branch 'main' into feat/t07-chat-response-envelope 2026-08-11 16:26:43 +08:00
xlb1130 6b85867309 Merge branch 'main' into fix/85313115-chat-catalog-tools 2026-08-11 16:23:13 +08:00
fengbai fdf3e8cc3b docs(skills): sync calendar reference between mono and multi layouts 2026-08-11 16:21:20 +08:00
前津 a5902ca233 Merge upstream/main into chat response branch 2026-08-11 16:20:06 +08:00
github-actions[bot] 0fb332c3f3 Merge pull request #934 from DingTalk-Real-AI/feat/eval-dispatch
ci: add /eval PR comment dispatch for internal MCP evaluation
2026-08-11 16:15:19 +08:00
xlb1130 28669ffeee Merge branch 'main' into fix/85313115-chat-catalog-tools 2026-08-11 16:04:49 +08:00
长真 fa5bc65d66 fix(chat): preserve conversation id alias contracts 2026-08-11 16:04:11 +08:00
前津 49afa82d27 chore: rerun ci 2026-08-11 15:37:41 +08:00
玉澜 3eda3b5ce6 docs: align unified framework scope with dev pilot 2026-08-11 15:32:06 +08:00
玉澜 49ab7a46f4 fix(devapp): preserve pagination contract during dry-run 2026-08-11 15:30:40 +08:00
长真 d500f2fe5f chore: rerun CI 2026-08-11 15:29:52 +08:00
Anonymity-0 bcc9e27da0 Merge branch 'main' into feat/t07-chat-response-envelope 2026-08-11 15:15:51 +08:00
玉澜 cf64f2ad02 Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 15:14:39 +08:00
玉澜 5ab46921c5 fix: preserve legacy errors and devdoc pagination contract 2026-08-11 15:13:18 +08:00
xlb1130 f8a031564a Merge branch 'main' into fix/85313115-chat-catalog-tools 2026-08-11 15:08:50 +08:00
玉澜 2989c1db37 Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 14:56:53 +08:00
Anonymity-0 eaee7f1c6f Merge branch 'main' into feat/t07-chat-response-envelope 2026-08-11 14:52:23 +08:00
xlb1130 103b188458 Merge branch 'main' into fix/85313115-chat-catalog-tools 2026-08-11 14:17:11 +08:00
长真 156d95e6d1 fix(chat): complete catalog leaf contracts 2026-08-11 14:16:23 +08:00
前津 b1f5c67e9c Merge upstream/main into chat response branch 2026-08-11 14:00:34 +08:00
玉澜 037deefe67 Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 14:00:07 +08:00
玉澜 06ed3aeeb3 fix: harden unified result rollout contracts 2026-08-11 13:47:07 +08:00
Anonymity-0 b244df1634 Merge branch 'main' into feat/t07-chat-response-envelope 2026-08-11 13:31:37 +08:00
长真 eb3f7328bb fix(chat): tighten catalog safety contracts 2026-08-11 12:17:31 +08:00
长真 3c445ce73a fix(chat): to #85313115 register missing catalog tools 2026-08-11 12:17:31 +08:00
玉澜 ef73257a69 Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 11:58:36 +08:00
Dennis 461b9b773a feat(minutes): align and expand shortcut workflows 2026-08-11 11:52:57 +08:00
wxianfeng bab7c8879b Merge remote-tracking branch 'upstream/main' into feature/aone85277391-event-runtime-token-handoff 2026-08-11 11:48:08 +08:00
wxianfeng 82b17ced32 Merge upstream/main into feature/aone85277391-event-runtime-token-handoff 2026-08-11 11:37:37 +08:00
Anonymity-0 7945f44c9a Merge branch 'main' into feat/t07-chat-response-envelope 2026-08-11 11:29:59 +08:00
wxianfeng 63dbf98cdf test(event): cover runtime token rejection on Windows to #85277391 2026-08-11 11:22:48 +08:00
玉澜 8034f0c2dc fix: preserve nested error operation context 2026-08-11 11:15:36 +08:00
玉澜 5b0e44290e Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 11:02:00 +08:00
liangxiaoqin.lxq 4bd9f75231 cr修复1 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq 8f8f64c391 cr修复,增加测试 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq ae9caa06af cr修复 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq ee0c3507a5 补充测试 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq 72a9902254 wiki feed list命令 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq 5f337e0ce5 wiki feed list命令:新增时间格式化/字段裁剪 2026-08-11 10:57:44 +08:00
Anonymity-0 6fdd17d3b6 Merge branch 'main' into feat/t07-chat-response-envelope 2026-08-11 10:43:18 +08:00
玉澜 5c2181a31d test: require envelope-safe fields projection 2026-08-11 10:30:23 +08:00
前津 956819663d chore: retrigger CI 2026-08-11 10:29:50 +08:00
玉澜 670ab1fd5e Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 10:24:36 +08:00
玉澜 b299400017 fix: preserve result envelope with fields 2026-08-11 10:23:59 +08:00
玉澜 3afcabc41d fix: report output publication failures 2026-08-11 10:20:05 +08:00
玉澜 a43e75e8df Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 00:18:18 +08:00
玉澜 2aad96fa7b Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-10 23:42:21 +08:00
玉澜 3fe2a7f5c0 fix: preserve emitted result exit codes on signals 2026-08-10 23:42:11 +08:00
玉澜 24fd2d2573 fix: use default legacy status rollout 2026-08-10 22:49:53 +08:00
玉澜 90d99d9bbe fix: preserve connect status output compatibility 2026-08-10 22:47:55 +08:00
玉澜 a37f614be4 test: cover unified schema validation edges 2026-08-10 22:15:04 +08:00
前津 08cf334cc1 Merge remote-tracking branch 'upstream/main' into feat/t07-chat-response-envelope 2026-08-10 21:41:03 +08:00
玉澜 12088f2d44 Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-10 21:24:58 +08:00
玉澜 d9c74fbe96 feat: add result schemas and devapp pagination 2026-08-10 21:23:23 +08:00
前津 1522653844 test(chat): cover existing operation context 2026-08-10 20:41:37 +08:00
前津 357f31376d fix(chat): preserve operation on read failures 2026-08-10 20:15:04 +08:00
前津 0f178f8382 ci: rerun interrupted tests 2026-08-10 19:37:49 +08:00
前津 910fb4a9b1 fix(chat): preserve legacy message context 2026-08-10 19:06:59 +08:00
前津 89feea7971 chore: rerun CI 2026-08-10 17:39:40 +08:00
前津 24b61b1c17 fix(chat): reject empty message read responses 2026-08-10 17:39:40 +08:00
前津 edbc8275b6 chore: rerun CI 2026-08-10 17:39:40 +08:00
前津 27afa806ca feat(chat): unify typed and shortcut message contracts 2026-08-10 17:39:40 +08:00
玉澜 b6101bdbc3 fix: preserve typed error fallback contract 2026-08-10 16:35:22 +08:00
玉澜 538f2aba6f fix: complete unified output lifecycle coverage 2026-08-10 16:19:07 +08:00
wxianfeng 5004ed8ae6 fix(event): securely hand off runtime token to detached bus to #85277391 2026-08-10 15:59:11 +08:00
玉澜 2359de69fa fix: preserve unified failures with output files 2026-08-10 15:08:35 +08:00
玉澜 b62f6c0c02 fix: reset unified results for each execution 2026-08-10 15:03:33 +08:00
玉澜 25b5e0b9fa Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-10 14:42:24 +08:00
玉澜 03bda02e04 test: close unified framework contract coverage 2026-08-10 14:41:41 +08:00
玉澜 4da1e52b08 fix(dev): make connect dry-run plans auditable 2026-08-10 13:47:56 +08:00
玉澜 409ee0cb84 refactor: keep signal escalation portable 2026-08-10 13:35:26 +08:00
玉澜 7cf7598ef2 fix(dev): preserve published connect safety metadata 2026-08-10 13:24:14 +08:00
玉澜 9b220d0ee6 test: keep signal coverage portable 2026-08-10 13:14:57 +08:00
玉澜 d7ae59753d fix: preserve legacy formatter bytes during rollout 2026-08-10 13:09:28 +08:00
玉澜 72b2af1d1d feat(dev): integrate unified command results 2026-08-10 13:00:14 +08:00
玉澜 bd41da8caf fix: make signal escalation portable 2026-08-10 12:48:11 +08:00
玉澜 cc0e179a8d refactor: remove protocol version naming 2026-08-10 12:28:28 +08:00
玉澜 e0f66384e2 fix: keep framework core lint-clean 2026-08-10 12:25:50 +08:00
玉澜 2dd067562e feat: add unified command result framework core 2026-08-10 12:22:46 +08:00
201 changed files with 32672 additions and 1307 deletions
+203 -46
View File
@@ -1,14 +1,17 @@
name: PR Eval Dispatch
# `/eval <products> [sha=<full-head-sha>] [cases=<ref>]` PR 评论 → 触发内网评测流水线,报告由内网 bot 回贴。
# `/eval <products> [sha=<full-head-sha>] [cases=<ref>]` PR 评论 → 生成可验证的评测请求,报告由 bot 回贴。
# 本 workflow 只在默认分支上下文运行,不 checkout、不执行 PR 代码。
# 审核 SHA 规则:评测他人 PR 必须显式携带 sha=(审阅背书凭据,验证
# 其恰为当前 open head);评测自己创建的 PR 可省略,自动钉住派发时刻
# 的当前 head(作者自背书,无第三方偷换窗口);内网 CI 另以
# 的当前 head(作者自背书,无第三方偷换窗口);受控评测执行端另以
# FETCH_HEAD 校验兜底派发后的变更。
# 授权两级:仓库 write/maintain/admin 可派发任意 PR;默认分支
# .github/eval-allowlist.txt 名单内的用户仅可派发自己创建的 PR。
# 触发通道与凭证全部经 secrets 注入,文件内不出现任何内网信息。
# 触发通道:workflow 先创建占位评论,再上传与本次 run/comment 绑定的
# 不可变 manifest artifact,最后把 artifact 指针写回同一评论。评论仅是
# 不可信通知;受控评测服务必须验证成功 run、artifact 与 manifest,并在
# 触发评测前原子占用 manifest.idempotency_key,重复占用只能 no-op。
on:
issue_comment:
@@ -31,8 +34,8 @@ jobs:
timeout-minutes: 5
permissions:
contents: read
issues: write
pull-requests: read
# 该 job 仅处理 PR;评论写入也限定在 PR Conversation 这一权限域。
pull-requests: write
steps:
- name: Check out default branch tooling
uses: actions/checkout@v4
@@ -66,12 +69,10 @@ jobs:
PARSE_ERROR: ${{ steps.parse.outputs.error }}
run: |
body="❌ /eval 命令解析失败:${PARSE_ERROR}"
jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \
-X POST \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
--data @- \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null
gh api --method POST \
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--raw-field body="$body" \
> /dev/null
exit 1
- name: Verify reviewed PR head
@@ -91,49 +92,205 @@ jobs:
| python3 scripts/ci/eval_dispatch_guard.py head \
>> "$GITHUB_OUTPUT"
- name: Trigger internal evaluation pipeline
env:
EVAL_TRIGGER_TOKEN: ${{ secrets.EVAL_TRIGGER_TOKEN }}
EVAL_TRIGGER_URL: ${{ secrets.EVAL_TRIGGER_URL }}
PR_NUMBER: ${{ github.event.issue.number }}
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
PRODUCTS: ${{ steps.parse.outputs.products }}
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
run: |
if [ -z "$EVAL_TRIGGER_TOKEN" ] || [ -z "$EVAL_TRIGGER_URL" ]; then
echo "EVAL_TRIGGER_URL / EVAL_TRIGGER_TOKEN not configured; cannot dispatch." >&2
exit 1
fi
jq -n \
--arg pr "$PR_NUMBER" \
--arg sha "$PR_HEAD_SHA" \
--arg products "$PRODUCTS" \
--arg cases "$CASES_REF" \
'{branch: "main", params: {pr_number: $pr, pr_head_sha: $sha, products: $products, cases_ref: $cases}}' \
| curl --fail --silent --show-error \
-X POST \
-H "private-token: ${EVAL_TRIGGER_TOKEN}" \
-H "Content-Type: application/json" \
--data @- \
"$EVAL_TRIGGER_URL"
echo "Internal evaluation dispatched."
- name: Acknowledge on PR
- name: Create dispatch placeholder
id: placeholder
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.issue.number }}
run: |
set -euo pipefail
placeholder_body="🛰️ /eval 请求已通过权限与版本校验,正在生成可验证的评测请求。"
response="$(
gh api --method POST \
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--raw-field body="$placeholder_body"
)"
comment_id="$(
printf '%s' "$response" \
| jq -er \
--arg issue_url "https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}" \
'select(.issue_url == $issue_url) | .id | tostring | select(test("^[1-9][0-9]*$"))'
)"
printf 'comment_id=%s\n' "$comment_id" >> "$GITHUB_OUTPUT"
- name: Build dispatch request manifest
env:
REPOSITORY_ID: '1187709537'
REPOSITORY: ${{ github.repository }}
WORKFLOW_ID: '331725458'
WORKFLOW_PATH: .github/workflows/eval-dispatch.yml
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
SOURCE_COMMENT_ID: ${{ github.event.comment.id }}
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
ACTOR_ID: ${{ github.event.comment.user.id }}
ACTOR_LOGIN: ${{ github.event.comment.user.login }}
PR_NUMBER: ${{ github.event.issue.number }}
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
PRODUCTS: ${{ steps.parse.outputs.products }}
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
SOURCE_BODY: ${{ github.event.comment.body }}
MANIFEST_PATH: ${{ runner.temp }}/eval-dispatch-request.json
run: |
set -euo pipefail
if [ "$REPOSITORY" != "DingTalk-Real-AI/dingtalk-workspace-cli" ]; then
echo "unexpected repository: ${REPOSITORY}" >&2
exit 1
fi
for value in \
"$REPOSITORY_ID" \
"$WORKFLOW_ID" \
"$RUN_ID" \
"$RUN_ATTEMPT" \
"$SOURCE_COMMENT_ID" \
"$DISPATCH_COMMENT_ID" \
"$ACTOR_ID" \
"$PR_NUMBER"; do
if [[ ! "$value" =~ ^[1-9][0-9]*$ ]]; then
echo "dispatch manifest contains a non-canonical identifier" >&2
exit 1
fi
done
if [[ ! "$PR_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "dispatch manifest contains an invalid PR head SHA" >&2
exit 1
fi
hash_output="$(printf '%s' "$SOURCE_BODY" | sha256sum)"
source_body_sha256="${hash_output%% *}"
if [[ ! "$source_body_sha256" =~ ^[0-9a-f]{64}$ ]]; then
echo "failed to hash source comment" >&2
exit 1
fi
idempotency_key="${REPOSITORY_ID}:${SOURCE_COMMENT_ID}"
umask 077
jq -n \
--arg repository_id "$REPOSITORY_ID" \
--arg repository "$REPOSITORY" \
--arg workflow_id "$WORKFLOW_ID" \
--arg workflow_path "$WORKFLOW_PATH" \
--arg run_id "$RUN_ID" \
--arg run_attempt "$RUN_ATTEMPT" \
--arg source_comment_id "$SOURCE_COMMENT_ID" \
--arg dispatch_comment_id "$DISPATCH_COMMENT_ID" \
--arg actor_id "$ACTOR_ID" \
--arg actor_login "$ACTOR_LOGIN" \
--arg pr_number "$PR_NUMBER" \
--arg pr_head_sha "$PR_HEAD_SHA" \
--arg products "$PRODUCTS" \
--arg cases_ref "$CASES_REF" \
--arg source_body_sha256 "$source_body_sha256" \
--arg idempotency_key "$idempotency_key" \
'{
schema_version: 1,
repository_id: $repository_id,
repository: $repository,
workflow_id: $workflow_id,
workflow_path: $workflow_path,
run_id: $run_id,
run_attempt: $run_attempt,
source_comment_id: $source_comment_id,
dispatch_comment_id: $dispatch_comment_id,
actor_id: $actor_id,
actor_login: $actor_login,
pr_number: $pr_number,
pr_head_sha: $pr_head_sha,
products: $products,
cases_ref: $cases_ref,
source_body_sha256: $source_body_sha256,
idempotency_key: $idempotency_key
}' > "$MANIFEST_PATH"
- name: Upload dispatch request manifest
id: artifact
uses: actions/upload-artifact@v4
with:
name: eval-dispatch-request-${{ github.run_id }}-${{ github.run_attempt }}-${{ steps.placeholder.outputs.comment_id }}
path: ${{ runner.temp }}/eval-dispatch-request.json
if-no-files-found: error
retention-days: 1
overwrite: false
- name: Finalize dispatch marker
env:
GH_TOKEN: ${{ github.token }}
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
REPOSITORY_ID: '1187709537'
WORKFLOW_ID: '331725458'
WORKFLOW_PATH: .github/workflows/eval-dispatch.yml
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }}
ARTIFACT_DIGEST: ${{ steps.artifact.outputs.artifact-digest }}
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
PRODUCTS: ${{ steps.parse.outputs.products }}
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
run: |
set -euo pipefail
if [[ ! "$DISPATCH_COMMENT_ID" =~ ^[1-9][0-9]*$ ]] || \
[[ ! "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]]; then
echo "artifact marker contains a non-canonical identifier" >&2
exit 1
fi
artifact_digest="${ARTIFACT_DIGEST,,}"
if [[ "$artifact_digest" != sha256:* ]]; then
artifact_digest="sha256:${artifact_digest}"
fi
if [[ ! "$artifact_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "artifact marker contains an invalid digest" >&2
exit 1
fi
marker_json="$(
jq -nc \
--arg repository_id "$REPOSITORY_ID" \
--arg workflow_id "$WORKFLOW_ID" \
--arg workflow_path "$WORKFLOW_PATH" \
--arg run_id "$RUN_ID" \
--arg run_attempt "$RUN_ATTEMPT" \
--arg dispatch_comment_id "$DISPATCH_COMMENT_ID" \
--arg artifact_id "$ARTIFACT_ID" \
--arg artifact_digest "$artifact_digest" \
'{
schema_version: 1,
repository_id: $repository_id,
workflow_id: $workflow_id,
workflow_path: $workflow_path,
run_id: $run_id,
run_attempt: $run_attempt,
dispatch_comment_id: $dispatch_comment_id,
artifact_id: $artifact_id,
artifact_digest: $artifact_digest
}'
)"
cases_note=""
if [ -n "$CASES_REF" ]; then
cases_note=",用例版本 \`${CASES_REF}\`"
fi
body="🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。内网评测流水线运行结束后将由 bot 回贴报告(首行为基线对比头条)。"
jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \
-X POST \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
--data @- \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null
body="<!-- eval-dispatch: ${marker_json} -->"$'\n'"🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。"$'\n'"受控评测服务将在数分钟内处理,完成后由 bot 回贴报告。"
response="$(
gh api --method PATCH \
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
--raw-field body="$body"
)"
printf '%s' "$response" \
| jq -e \
--arg comment_id "$DISPATCH_COMMENT_ID" \
--arg body "$body" \
'((.id | tostring) == $comment_id) and (.body == $body)' \
> /dev/null
- name: Mark dispatch preparation failure
if: ${{ failure() && steps.placeholder.outputs.comment_id != '' }}
env:
GH_TOKEN: ${{ github.token }}
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
run: |
failure_body="❌ /eval 请求准备失败,未生成可消费的评测请求。请稍后重试。"
gh api --method PATCH \
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
--raw-field body="$failure_body" \
> /dev/null \
|| true
+132 -3
View File
@@ -464,6 +464,134 @@ Keep CLI confirmation behavior and Schema metadata consistent, and add a
semantic regression test through the final embedded loader/query delivery
path; a generator unit test or JSON count alone is insufficient.
## Unified result Schema and performance
The unified runtime envelope and the per-command Schema result declaration are
related but distinct contracts:
- Runtime owns the outer machine envelope (`ok`, `outcome`, `data`, `error`,
`meta`) and derives it through `internal/output`. Business commands return a
`CommandResult`; they must not hand-author the outer JSON shape.
- A leaf `Contract.Result` / `contract.ResultSpec` describes the reviewed
business value inside `data`. It may declare `outcomes`, `data_schema`, and
`sensitive_paths`. `Contract.Pagination` is a separate command capability
because pagination is emitted under envelope `meta`, not inside `data`.
- `outcomes` is the set of results a command may produce; it is not the outcome
of the current invocation. `data_schema` is a JSON Schema object for business
data and must not duplicate the framework envelope.
- Result declarations are delivered in the full leaf and in the reviewed
`--compact` Agent projection. Compact retains the normalized `result` object
verbatim but still omits provenance, interface bindings, and other audit-only
fields. Product/group summaries remain navigation views and need not repeat
every leaf Result. When an Agent needs return-shape facts, query the compact
leaf directly; do not load the whole full Catalog.
- A missing `result` means “no reviewed return-value declaration is published
for this leaf.” It does **not** prove that the runtime is legacy, and it must
not be filled by inference from examples, MCP samples, or previous command
output. Runtime rollout remains an internal per-command fact.
- The public contract has no `contract_version`, no `--output-contract`, and no
Agent-selectable protocol alias. Agents continue to request machine output
with `--format json`; migrated commands use the unified result directly and
unmigrated commands retain their current legacy output.
- Existing `dev` / `devapp` pilot coverage is gradual. Active reviewed
`devapp` shortcuts are gated on a non-empty Result declaration, while `dev`
currently has representative Result coverage. Do not describe that as
repository-wide coverage. Any newly activated Agent-visible command should
add and test its Result declaration; the remaining pilot gaps should shrink,
not expand.
The compact/full leaf `result` object has one stable shape:
```json
{
"result": {
"outcomes": ["success", "pending", "partial_failure", "failure"],
"data_schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Stable resource ID"},
"name": {"type": "string", "description": "Display name"}
}
}
}
}
},
"sensitive_paths": ["credential.secret"]
},
"pagination": {
"kind": "cursor",
"cursor_parameter": "cursor",
"meta_path": "meta.pagination",
"endpoint_exhausted_path": "meta.pagination.endpoint_exhausted",
"next_token_path": "meta.pagination.next_token"
}
}
```
Field rules:
| Field | Required | Contract |
|---|---|---|
| `outcomes` | yes | Non-empty unique subset of `success`, `pending`, `partial_failure`, `failure`; normalization publishes canonical order. |
| `data_schema` | yes | One recursive JSON Schema **object** describing only the runtime envelope's `data` value. Every named `properties` child must have a non-empty `description`. It must not duplicate `ok`, `outcome`, `error`, or `meta`. |
| `sensitive_paths` | no | Unique safe dot paths relative to `data`; renderers/redaction consumers must not treat them as shell/JQ expressions. |
Optional members are omitted, never emitted as `null`. A leaf without a
reviewed Result omits the entire `result` key. Compact must preserve the same
normalized Result value as the full leaf; it must not summarize, infer, rename,
or independently rebuild any Result field. Product/group summaries do not
aggregate child Result objects.
`pagination` is a sibling of `result`, not a child. It declares the canonical
CLI cursor parameter and the fixed framework paths under `meta.pagination`.
Product response fields used to derive that metadata remain mapper internals;
they are not part of `result.data_schema`. Do not execute a second request to
derive pagination metadata.
Invalid result declarations fail closed during normalization: unknown or
duplicate outcomes, a non-object/multiple `data_schema`, unsafe or duplicate
sensitive paths, unsupported pagination kinds, attempts to override framework
meta paths, and an invalid cursor parameter must be rejected rather than
silently removed.
Full-leaf wire round trips must
preserve the normalized Result exactly. Do not commit generated Schema JSON as
evidence; tests construct contracts in Go and runtime/CI assemble the Catalog
from declarations.
### Performance model and rules
- Catalog construction is declaration-driven and cached through the existing
lazy `sync.Once` delivery path. Do not reassemble or reopen annotations per
command invocation, per leaf lookup, or per renderer.
- Normalizing one Result declaration is linear in the size of that declaration.
Full `schema --all` is linear in tools + parameters + Result schema bytes and
is an audit/compatibility export, not the normal Agent discovery path.
Overview → compact product/group → compact leaf remains the normal route;
only the final leaf carries its Result declaration.
- Constructing a `CommandResult` defensively clones result data and validates
invariants; rendering is buffer-first and then writes once. Both CPU cost and
transient memory are O(payload size), with roughly one additional in-memory
rendered copy. This buys immutability and prevents partial JSON leakage, but
it is not free.
- Large list/search commands must use bounded pages and publish continuation
facts. The current emitter buffers one command result/page before publishing;
pagination is the memory bound. Continuous event streams are a separate,
command-specific protocol and are not described by `ResultSpec`.
- A `dual_validate` command must execute the business request exactly once,
validate a shadow unified result, and preserve legacy bytes. Never obtain
validation by issuing a second network or write request.
- Filters and alternate formats are render-time work over the same in-memory
result. They must not rerun the business operation or rebuild Schema.
- Performance changes must preserve the one-result, buffer-first, fail-closed,
and atomic `--output` guarantees. Do not trade correctness for a microbenchmark
improvement. For a material hot-path change, benchmark representative small
and page-sized payloads and report allocations/bytes as well as latency.
## Current Schema boundaries
- `schema list` remains a progressive overview. `schema --all` is the stable
@@ -479,8 +607,9 @@ path; a generator unit test or JSON count alone is insufficient.
a complete compatibility baseline.
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
executable accepts. A compact leaf defines Agent selection, CLI parameters,
constraints, and safety/confirmation semantics. Full leaf fields such as
`property`, `interface_ref`, and provenance are audit facts. A conflict is
contract drift, not permission to guess.
constraints, safety/confirmation semantics, and any reviewed `result`
contract. Full leaf fields such as `property`, `interface_ref`, and
provenance are audit facts. A conflict is contract drift, not permission to
guess.
- Schema and Help describe commands; neither returns DingTalk business data.
After discovery, execute the real read/search/list command to obtain data.
+30
View File
@@ -6,6 +6,8 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.58-beta.3] - 2026-08-11
### Added
- **Aitable workflow execution and history** — adds `dws aitable workflow run` for confirmed asynchronous execution of scheduled or record-triggered workflows, plus `dws aitable workflow history` for status-, time-, and page-filtered execution records. The commands map directly to `aitable/run_workflow` and `aitable/get_flow_record_list`, validate trigger-specific arguments locally, and document the `executionId` / `instanceId` correlation.
@@ -13,6 +15,9 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
`--at-open-dingtalk-ids` and `--at-all` for group cards, passing mention
targets to the initial card-creation request and prepending its returned
`atTag` to the automatic streaming update.
- **Personal OA approval events** — personal event consumers now support task
creation, completion, redirection, instance start, termination, and
completion events, with typed output and matching usage documentation.
### Fixed
@@ -22,6 +27,18 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
a successful local write. The result includes the saved path and byte size;
document exports additionally report the node, requested format, job/task
ID, and final status.
- **IM search and card-write safety** — conversation-scoped search now fails
closed when the target cannot be verified, and streaming-card updates require
business evidence rather than a transport-only success response.
- **Document shortcut reliability** — document write, readback verification,
pagination, template/version discovery, export, media, and local-file
workflows now preserve compatibility while rejecting ambiguous write results.
- **Event runtime-token handoff** — personal `event consume`, `status`,
`stop`, and `+listen-im` honor the root `--token` without falling back to a
stale OAuth profile. Detached buses negotiate an owner-only, memory-only IPC
credential channel; tokens are never placed in child argv, environment,
profiles, logs, or run-state files.
### Changed
- **Minutes `permission apply --policy` type** — `--policy` is now declared as
@@ -31,6 +48,15 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
- **Minutes skill references** — document `permission apply` in both Minutes
skill references: list it in the command trees, describe its policy values and
how it differs from `permission add`, and add its intent routing.
- **Chat paging guidance** — typed chat message commands now document
`--page-all`, aggregate result shapes, and cursor behavior in CLI Help and
Agent selection examples.
- **Calendar skill parity** — mono and multi Calendar references are aligned to
prevent documentation drift without changing CLI behavior.
- **Release engineering** — CI now shards helper-package changes through the
full race suite, widens a flaky stdio idempotency test budget, governs exact
reviewed CLI/Schema type migrations, and lets authorized maintainers trigger
internal MCP evaluation with a reviewed `/eval` PR comment.
## [1.0.58-beta.2] - 2026-08-10
@@ -60,6 +86,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
- **CLI 接口兼容门禁支持 reviewed flag 类型豁免**(无用户可见变更)— `authoritative-interface-integrity` 与 `check-command-compatibility.sh` 此前一律拒绝历史命令的 flag 类型变更,即使新类型只是把同一套校验从 RunE 前移到解析期,也没有任何评审通道。现在两道门禁各带一张精确豁免表:命令路径 + flag 名 + 旧类型 → 新类型四元组全等才命中、方向敏感(`string`→`int` 与 `int`→`string` 是两个不同的键,只有被评审的方向可用),且仅当该 flag 的其他契约(shorthand / required / hidden / no-opt / scope)纹丝不动时才放行,因此豁免夹带不了别的破坏。首条也是目前唯一一条登记的是 `dws minutes permission apply --policy` 的 `string` → `int`(配合 #912):旧实现在 RunE 里做 `strconv.ParseInt(v, 10, 64)` 再校验 `[2,4]`,新实现由 pflag 以 `strconv.ParseInt(s, 0, 64)` 解析后仍校验 `[2,4]`,**历史上能成功的调用集是新调用集的子集**(base 0 额外接受 `0x3` 这类写法,只放宽不收紧),非法值依然失败、只是报错文案与时机前移;flag 默认值由 `""` 变 `"0"` 是类型的必然结果,两道门禁都不比较默认值,且该 flag 必须显式给出、默认值不可达。两张表必须逐字一致并有守卫测试锚定漂移——重复是被迫的而非选择:`check-authoritative-interface-baselines.sh` 会把整个 `scripts/policy/interface-baseline` 目录复制进检出历史版本的 worktree 再编译,那份拷贝不能 import 本分支新增的包。
- **Schema 兼容门禁支持 reviewed 参数类型豁免**(无用户可见变更)— 接上一条。`schema-compatibility` 是同一个 `Interface Integrity` job 里排在两道 CLI 接口门禁之后的第三道检查,此前也一律拒绝已发布参数的 `type` 变更。由于前两道先失败、`set -e` 让它从未在 CI 上暴露,上一条豁免只解决了三分之二。现在 `checkParameterCompatibility` 也带一张精确豁免表:`<product>/<tool id>` + 参数名 + 旧类型 + 新类型四元组全等才命中、方向敏感,且仅当该参数**除 `type` 外的全部已发布字段逐字段相等**时才放行。这里刻意用相等性比较而非「没有产生其他兼容性错误」:放宽 `required` / `cli_required`、清空 `required_when`、扩宽 `enum`、清空 `interface_type`、经 reviewed mapping exclusion 清空 `property`——这些变化单独看都是兼容的、根本不产生错误,若以错误列表代替相等性检查,它们就能搭着一次已评审的类型迁移一起蒙混过关。结构体整体比较还意味着将来给 `parameterSchema` 新增字段时会自动纳入守卫,而不是悄悄放宽每一条既有条目。唯一条目是 `minutes/minutes.apply_minutes_permission` 的 `policy` 由 `"string"` 迁移到 `"integer"`(配合 #912):该 `type` 由 Cobra flag 类型投影而来(provenance `cobra_flag_type`),描述的是 CLI 如何接受取值;消费方据此拼装的是命令行,而 `--policy 4` 在两种声明下是同一个 argv,加引号的 `--policy "4"` 到 pflag 仍是 4,RunE 也仍校验 `[2,4]`——而且该参数映射的 property `policyId` 一直以数字上报,新声明比旧声明更贴近真实请求。表里的类型值必须是 `schemaType` 实际产出的带引号形态(`"string"` 而非裸 `string`),守卫测试用 `schemaType` 复算并校验类型名属于 JSON Schema 的封闭取值集合——`reviewedInterfaceRefRedirect` 曾因键的书写形态错误两次静默失效,这里不重犯。
### Fixed
- **Event runtime-token handoff** — personal `event consume`, `status`, `stop`, and `+listen-im` now honor the existing root `--token` instead of falling back to a stale local OAuth profile. Detached personal-event buses negotiate the credential only after an additive capability handshake, receive and rotate it through owner-only local IPC, and keep it in memory; the token is never forwarded through child argv, environment variables, profiles, logs, or run-state files. Existing OAuth and multi-profile behavior is unchanged when `--token` is absent. A new client refuses to send a runtime token to an older bus and leaves its existing consumers and subscriptions untouched; the recovery message asks users to inspect `event status --as user`, preview `event stop --as user --all --dry-run`, and explicitly confirm `event stop --as user --all --yes` before retrying.
## [1.0.58-beta.1] - 2026-08-07
### Added
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.58-beta.2"
version "1.0.58-beta.3"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-arm64.tar.gz"
sha256 "1b2b6953f7f1ae1ca6ecb0702424ac0e1a976a6a5ff91e8ffc3b5ae495d98c7c"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-darwin-arm64.tar.gz"
sha256 "29b4fb9e081f36a699933c0919fe7530544f62de3e27c785d27626a575a2efc2"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-amd64.tar.gz"
sha256 "a1c1b3c58b48e04c0ae520062f9d6ab0dc961eddb635497bdb9b4345316e45f6"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-darwin-amd64.tar.gz"
sha256 "a6b9c4ef212c533e02b414bd9c3be0b8d1874d4af983a7896072825bdfec1033"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-arm64.tar.gz"
sha256 "7f35e3c4734f17b125a8c32f3c95e05d1410f683cf6956be857ee9349f8e4d36"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-linux-arm64.tar.gz"
sha256 "ae3a9ebe151702fd05dee0c56d778a20789d98c390cf8c0a0b2ec695b57b5ec3"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-amd64.tar.gz"
sha256 "37beb9e39790563cf0584ac23376f713bf2eb2c50cff4222965e831ac9adbb0e"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-linux-amd64.tar.gz"
sha256 "544b480701e9ec9ec5366467ad4c855fca06dea887e3d577ff50e4b3eeb13ac2"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-skills.zip"
sha256 "7e10fead4192059c98d596c5b1886f77fd550526de5cd18c425cdad6fd64cd3a"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-skills.zip"
sha256 "322f1840442ff183ad4b6d4f2a2b38825ff9f96a3fcde06ba57b8f80647468ae"
end
def install
+41 -1
View File
@@ -4,7 +4,7 @@ Defines the stable `dws event consume` subprocess contract so an
orchestrator can determine when the consumer is ready, stop it cleanly,
and machine-read why it exited.
Scope of this branch: the five **contract** items below. Reconnect
Scope of this branch: the six **contract** items below. Reconnect
resilience (keeping the stream alive across a transient upstream drop) is
tracked separately and intentionally out of scope here.
@@ -159,6 +159,46 @@ marker; reconnecting an established Stream remains a separate mechanism.
`terminal_hold`, and identity-scoped cleanup; skill/docs tests pin the
operational recovery instructions.
### 6. Host runtime-token handoff
When the root command carries an explicit host-supplied `--token`, personal
event control requests and the foreground Stream use that token with higher
priority than local OAuth. A detached bus receives it only through the
owner-only local IPC transport:
1. The child starts in runtime-token mode with non-sensitive identity and
ticket metadata only; neither its argv nor environment contains the token.
2. The consumer sends `Hello` with `credential_mode=runtime_token`.
3. The bus advertises the additive `runtime_token_v1` capability and its
in-memory credential generation in `HelloAck`.
4. Only after that capability is confirmed does the consumer send a bounded
`credential_update` frame. The bus applies it with generation CAS, replies
with `credential_update_ack`, and registers the consumer only on success.
The bus blocks ticket acquisition until the first runtime credential arrives.
A later invocation may rotate Token A to Token B on a compatible existing bus;
the current WebSocket remains connected and the next ticket request or natural
reconnect uses B. If a 401 rejects the current runtime token, only an already
installed newer generation is retried; the runtime path never refreshes or
falls back to a local OAuth profile and never suggests `dws auth login`.
Clients do not send a token to a bus that lacks the capability, do not stop
other consumers automatically, and fail before printing the ready marker. With
no explicit `--token`, the original OAuth, refresh, profile, and old-client to
new-bus protocol behavior remains unchanged.
**Verification**
- T6a: a stale local Token A and root Token B produce control and ticket
requests authenticated only with B.
- T6b: compatible bus reuse supports A-to-B rotation and generation conflicts;
401 retries only an already-installed newer runtime token.
- T6c: an old bus receives no credential and remains running; the new consumer
exits before its ready marker.
- T6d: a canary credential is absent from child argv/environment, dry-run,
stdout/stderr, `bus.meta`, `bus.log`, run state, and returned errors.
- T6e: no-token OAuth, refresh, multi-profile, marker/cache, and bus-reuse tests
continue to pass.
## Out of scope (next branch)
**Reconnect resilience** — today `personal source` retries only
@@ -0,0 +1,134 @@
# 独立 `meta.pagination` Schema 方案
## 1. 目标结构
业务结果与分页控制信息分层:
```json
{
"ok": true,
"outcome": "success",
"data": {
"items": [{"id": "a"}]
},
"meta": {
"pagination": {
"endpoint_exhausted": false,
"next_token": "cursor-2"
}
}
}
```
对应 compact/full leaf Schema:
```json
{
"result": {
"outcomes": ["success", "failure"],
"data_schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"description": "当前页业务记录",
"items": {"type": "object"}
}
}
}
},
"pagination": {
"kind": "cursor",
"cursor_parameter": "cursor",
"meta_path": "meta.pagination",
"endpoint_exhausted_path": "meta.pagination.endpoint_exhausted",
"next_token_path": "meta.pagination.next_token"
}
}
```
`result` 只描述 `data`;`pagination` 是与 `result` 同级的命令能力声明。
## 2. 分页状态
| 状态 | `endpoint_exhausted` | `next_token` | Agent 行为 |
|---|---:|---|---|
| 可续跑 | `false` | 必须非空 | 将 token 传给 `--<cursor_parameter>` |
| 已耗尽 | `true` | 必须省略 | 停止翻页 |
`endpoint_exhausted:true` 只表示观察到 Endpoint 分页耗尽,不表示搜索索引
健康、数据全量覆盖或业务对象不存在。
## 3. 映射规则
产品 mapper 可以读取服务端原始 `hasMore/nextCursor`、`has_more/page_token`
等字段,但统一 CLI 输出只公布 `meta.pagination`:
- 服务端表示还有下一页且 cursor 非空 → `endpoint_exhausted:false` + token。
- 服务端表示没有下一页 → `endpoint_exhausted:true`,不带 token。
- 表示还有下一页但 cursor 缺失、类型错误或证据冲突 → typed
`pagination_inconsistent`,禁止伪装终页。
- mapper 使用同一份上游响应构造 `data` 与 `meta`,不得重新请求。
原始分页控制字段不进入新的 `result.data_schema`。未迁移命令保持 legacy;
已迁移命令按命令独立切换和回滚,不通过 Agent 参数选择协议。
## 4. Schema 规则
- `kind` 当前只允许 `cursor`。
- `cursor_parameter` 是真实 canonical CLI flag 名,不带 `--`,并必须存在于
同一 leaf 的 `parameters`。
- 三个 meta path 由框架固定生成,产品不能覆盖。
- compact/full leaf 同时包含相同的 `result` 和 `pagination`。
- product/group 导航摘要不复制分页对象;Agent 需要时查询具体 compact leaf。
- 没有 `pagination` 表示该命令尚未发布经评审的分页能力,Agent 不得猜测。
## 5. 渐进接入
1. **legacy_only**:保持原输出,不公布分页声明。
2. **dual_validate**:业务执行一次;影子构造并校验 `meta.pagination`,外部
legacy 字节不变。
3. **unified_active**:输出独立 `meta.pagination`,Schema 公布同级
`pagination` 声明。
4. **unified_stable**:Skill、示例和 Agent 审计均只读取 meta 分页。
不增加 `contract_version`、`--output-contract` 或分页协议别名。
## 6. 验收
每个分页命令至少验证:
1. 有下一页时 `endpoint_exhausted:false` 且 token 非空。
2. 终页和空终页为 `endpoint_exhausted:true` 且无 token。
3. 分页矛盾产生 typed failure,不 panic、不静默停止。
4. `cursor_parameter` 在 Help/Schema 中真实存在。
5. compact/full 的 `result`、`pagination` 分别 JSON 等价。
6. `data_schema` 不包含分页控制字段。
7. 运行时 `data` 不包含迁移后的分页控制字段。
8. dual validate 与 active 都只消费一次上游响应。
9. Agent 逐命令扫描结果进入评测台账;不提交生成 Schema JSON fixture。
### DevApp 首批落地
以下 8 个终结命令已发布独立 `pagination` Schema;运行时统一输出只在
`meta.pagination` 返回分页控制信息:
- `dev app list`
- `dev app permission list`
- `dev app event list`
- `dev app version list`
- `devapp +list`
- `devapp +permission-list`
- `devapp +event-list`
- `devapp +version-list`
两套既有命令前缀继续保留。原子命令的业务记录字段为 `data.items`;Shortcut
保留既有业务投影(例如 `data.apps`、`data.permissions`、`data.events`、
`data.versions` 以及 `data.count`),但两套入口都不再在业务数据中公布
`hasMore/nextCursor`。
## 7. 对齐依据
GWS 用请求参数和 response schema 描述分页事实;Lark 在统一输出层维护分页
元数据。DWS 采用更明确的分层:业务 `data` 保真承载记录,框架 `meta` 承载
续跑状态,Schema 用独立能力把 token 与下一次 CLI 参数连接起来。
-188
View File
@@ -1,188 +0,0 @@
# lark-cli Shortcut 深度对齐矩阵
> 12 个 agent 逐条深读 lark 每个 shortcut 的智能实现(Validate/DryRun/ID解析/投影/多步/分页),映射钉钉、标注保真度差距。
## 2026-07-13 最新源码复核
对比基线:
- DWS:`feature/shortcut@b7c14c1`(已合并 `origin/main@390b611`)
- lark-cli:`main@e96c4fa5`
- lark-cli 本轮更新范围:`f495cbb1..e96c4fa5`
本轮 lark-cli **没有增加或删除生产 shortcut 命令**,变化集中在已有命令的实现保真度:统一 `--json` shorthand、文档分享锚点读取、whiteboard 本地文件安全内联、VC meeting events 的 identity/timeline/NDJSON 投影、Apps DB 环境自动选择、Drive push 错误分类,以及 Wiki token 解析兼容性。因此下方历史 gap 清单的命令面没有因本轮 pull 新增条目,但若要追平体验,以下实现差距需要上调优先级。
### 当前命令面快照
| 指标 | 数量 | 说明 |
|---|---:|---|
| DWS built-in shortcut | 366 | 16 个服务;运行时 registry 实测 |
| lark-cli primary shortcut | 363 | 19 个服务;排除 `_test.go` 与 42 个 `sheets/backward` 隐藏兼容别名 |
| 双方可映射服务内命令 | DWS 313 / lark 324 | 12 组产品映射,不含平台特有服务 |
| 同服务同名命令 | 50 | 仅是名称交集,不等于语义等价或保真度一致 |
| DWS 平台特有 shortcut | 53 | attendance / ding / oa / report 等 |
| lark 平台特有 shortcut | 39 | okr / vc / slides / markdown / whiteboard / note / event |
双方重叠服务的命令面如下;“同名”只用于定位,能力判断仍需看参数、验证、多步编排、输出投影和 dry-run:
| 产品映射 | DWS | lark | 同名 |
|---|---:|---:|---:|
| aitable ↔ base | 82 | 87 | 31 |
| calendar ↔ calendar | 23 | 10 | 3 |
| chat ↔ im | 89 | 21 | 2 |
| contact ↔ contact | 16 | 2 | 1 |
| devapp ↔ apps | 30 | 63 | 3 |
| doc ↔ doc | 19 | 14 | 1 |
| drive ↔ drive | 9 | 26 | 3 |
| mail ↔ mail | 10 | 21 | 0 |
| minutes ↔ minutes | 13 | 9 | 1 |
| sheet ↔ sheets | 2 | 42 | 0 |
| todo ↔ task | 13 | 17 | 2 |
| wiki ↔ wiki | 7 | 12 | 3 |
### 最新优先差距
1. **文档与白板资源保真度**:lark `doc +fetch/+update` 已支持分享链接 selection anchor、HTML5 block 资源引用,以及相对路径内的 SVG/Mermaid/PlantUML whiteboard 安全内联。DWS 具备文档读写和媒体原子能力,但缺少统一引用解析、路径门禁和资源回写编排。
2. **Sheets typed workflow**:lark 的 typed table、批量样式、维度移动/冻结、range copy/fill/sort、workbook import/export 仍是最大可建设缺口。DWS 原生 helper 已有部分底层能力,但 shortcut 层只有 2 个精选命令,缺少跨 sheet 分块写、类型推断和 partial rollback。
3. **Drive 本地同步体验**:lark `+push/+pull/+sync/+import/+export` 带批量计划、错误分类、路径保护和版本操作;DWS 目前偏原子上传/搜索,缺完整目录同步和可恢复批处理。
4. **Mail 高保真写链路**:lark 对 send/reply/reply-all/forward 提供模板、签名、HTML lint、线程头、定时和附件编排;DWS 有底层发信/草稿工具,但 smart shortcut 尚未覆盖这些组合体验。
5. **消息资源与统一搜索**:DWS 已有 `+search-msg/+chat-messages/+thread-replies/+at-me` 等拆分场景,lark `+messages-search` 仍在统一多维过滤、会话上下文富化、reaction/资源下载方面更完整。
6. **会议事件输出**:lark `vc +meeting-events` 本轮新增当前身份、actor、会议状态推断、timeline 与 NDJSON 元数据。DWS 最新 main 已有更强的实时 event bus 和个人事件订阅,但尚未沉淀成同等级 shortcut 投影;这是“底层能力领先、shortcut UX 未收口”。
### 不建议机械追平
- lark Apps DB、Spark 发布、Lark Drive/Wiki 特有对象模型属于平台差异,不应只为同名率复制。
- DWS 的 attendance、DING、OA、report、agoal 和最新 event bus 是钉钉侧差异化能力,应优先做场景化组合,而不是追求 363 vs 366 的数字对齐。
- DWS 已具备按姓名解析、跨产品智能编排、失败回滚和 usage→自定义 shortcut 沉淀闭环,这些能力无法由同名命令统计体现。
> 注:下方“361 条”汇总是上一轮逐条人工分类的历史基线;当前 lark-cli primary shortcut 是 363 条,另有 42 个不应重复计为能力的 Sheets 隐藏兼容别名。历史条目的判断仍可复用,但总量数字不能直接代表本轮最新覆盖率,后续应把新增条目按 covered-1to1 / covered-smart / gap-buildable / no-dingtalk-tool 四类补录。
## 汇总(361 条 lark shortcut)
| dws_status | 数量 | 含义 |
|---|:---:|---|
| covered-1to1 | 144 | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
| no-dingtalk-tool | 127 | 钉钉无对应工具,客观不可对齐 |
| **gap-buildable** | **41** | 钉钉有工具、值得补成智能 shortcut(**建设目标**);已建 minutes `+detail`/`+replace-batch`、base `+record-share-links`/`+resolve-base`、im `+thread-replies`/`+chat-messages`/`+chat-list`、task `+related-tasks` |
| covered-smart | 49 | 已建智能 shortcut / 部分覆盖 |
## 🎯 gap-buildable 目标清单(原 49 条,已建 8 → 剩 41,按服务)
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、im `+chat-list`(✅ smart `chat +chat-list`)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
### im → chat(7)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` + `--exclude-muted` + page-size/page-token 别名 + `--page-all/--page-limit` 数字 cursor 自动翻页、跨页去重、合并后类型过滤和完整性 ledger。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊互斥解析、时间范围、asc/desc、时间边界全量翻页、reaction、资源下载与完整性 ledger |
| `+chat-search` ✅ | read | **已建 smart `chat +chat-search`**:真实 `search_groups` 关键词搜索 + page-size/page-token 别名 + `--page-all/--page-limit` 不透明 cursor 自动翻页、跨页去重和完整性 ledger。Lark v2 的 member/type/mode/manager/sort 过滤没有可验证的钉钉对应参数,未伪造 |
| `+flag-list` ✅ | read | **已建 smart `chat +flag-list`**:真实 `list_message_favorites` 的 `items + hasMore + 数字 nextCursor`,支持 page-size/page-token、`--page-all/--page-limit`、跨页去重和完整性 ledger;仅对齐 message favorite,不模拟 Lark Feed thread flag |
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
| `+messages-search` ✅ | read | **已建 smart `chat +search-msg`**:统一多维过滤、精确时间范围、asc/desc、cursor 全量翻页、mget 富化、reaction、资源下载与完整性 ledger。剩余差异是 Lark chat 上下文和部分 sender/attachment 类型过滤 |
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:支持主消息 ID 自动只读解析 conversation/thread,也支持显式 group + thread/topic ID;list_topic_replies + sender/text/time/reaction/resource 投影 + 下层毫秒级 nextCursor 有界自动翻页、跨页去重、完整性 ledger,以及全量结果 asc/desc。与 Lark 的剩余差异是钉钉底层没有服务端 asc 单页,因此 DWS 的 asc 明确要求 `--page-all`,避免伪全局排序 |
### task → todo(3)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+reminder` | write | dws 有 add_todo_reminder/reset_todo_reminder 但无 lark 的先查现有再替换编排、相对时间(15m/1h)解析与互斥校验,值得补智能 shortcut |
| `+get-related-tasks` ✅ | read | **已建 smart `todo +related-tasks`**:creator+executor+participant 三角色并集 + taskId 去重 + 投影。剩余未做:followed-by-me 成员比对、subtask_count/tasklists 富投影 |
| `+upload-attachment` | write | dws add-attachment 走 init→PUT→commit 三步 MCP 上传(能力更重),但无 50MB/regular 校验、applink 提取与 dry-run 计划展示;可对齐成更智能 shortcut |
### calendar → calendar(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+room-find` | read | dws 有 room search(query_available_meeting_room 按单一时间段+过滤)和 busy search,但无多slot并发room_find聚合、无city/building/floor/capacity维度过滤、无按attendee推荐可用室,值得补成智能 shortcut 但未建 |
### doc (docs) → doc(2)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+media-insert` | write | dws doc media insert 为3步(取凭证→PUT→insert_document_block)无回滚、无selection定位、无剪贴板、无宽高比补算、无wiki解析;可补成带回滚的智能shortcut |
| `+media-download` | read | dws doc media download 走resourceId→downloadUrl两段,缺whiteboard导图分支、自动扩展名、路径安全、overwrite防护;media分支可对齐,whiteboard无工具 |
### drive → drive(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+import` | write | dws drive upload 有 --workspace --convert 可转在线文档,但缺按目标类型(docx/sheet/bitable/slides)导入、缺 target-token 挂载与异步轮询 |
### mail → mail(4)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+reply` | write | dws reply 走 create_reply_draft+send_draft 两步、附件仅上传会话,缺 EML 线程头构造、签名自动注入、模板合并、HTML lint、读回执、send-time 定时、跨字段校验 |
| `+reply-all` | write | dws reply-all 两步且收件人由服务端决定,缺原文收件人抽取去重排己、线程头、签名/模板/lint/定时等编排保真 |
| `+send` | write | dws send_email 单步(附件时先 create_draft 再传再 send),缺签名/模板/lint/日历内嵌/定时发送/发件人profile解析/跨字段校验 |
| `+forward` | write | dws forward 走 create_forward_draft+send_draft,缺 Fw:主题/引用块/原附件转载 EML 构建、签名/模板/lint/定时保真 |
### wiki → wiki(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+node-get` | read | dws 无 get_node 对应 tool(proxy wiki doc read 读的是文档正文而非节点元数据/space解析);缺 token/obj_token/URL→node 解析、obj_type推断、space交叉校验——是值得补的智能 shortcut 缺口 |
### minutes → minutes(4)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+search` | read | dws list_by_keyword_and_time_range 只按 keyword+时间+归属(created/shared)过滤,缺 owner/participant 的 me 解析与筛选、缺 query 长度与跨字段互斥校验、缺输出投影与去头像 |
| `+download` | read | dws 只有 query_minutes_audio_url 返回 OSS 地址(相当于 --url-only 单条),缺真正落盘下载、批量 fanout+限速+去重、文件名推断、SSRF 防护与覆盖保护 |
| `+word-replace` ✅ | write | **已建 smart `+replace-batch`**:多组 `原文=>替换` 批量替换 + 去重校验 + 逐组结果聚合(补齐 1:1 `+word-replace` 的单组限制)。剩余未做:@file/stdin 输入 |
| `+detail` ✅ | read | **已建 smart `+detail`**:单命令按 `--artifacts` fanout basic/summary/keywords/transcript/todos + partial-failure 容错 + rt.Output 投影。剩余未做:wait-ready 轮询、transcript 落盘 |
### base → aitable(10)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+title-resolve` ✅ | read | **已建 smart `aitable +resolve-base`**:search_bases 按名解析 baseId + 0/1/多候选消歧投影。剩余未做:Drive doc_wiki 全文搜索 |
| `+field-create` | write | dws create_fields 支持批量,但缺 formula/lookup guide-ack 门禁与逐字段节流,可补智能 shortcut |
| `+field-update` | write | dws update_field 缺 formula/lookup guide-ack 保护 |
| `+record-share-link-create` ✅ | read | **已建 smart `+record-share-links`**:>20 条记录去重 + 分片(≤20/批) + 跨 aitable-helper server fanout + 合并 {recordId,shareUrl},补齐单批 20 条上限 |
| `+record-upload-attachment` | write | dws 只有 prepare_attachment_upload(拿上传凭证),缺 分片上传编排+append_attachments 回填单元格的完整链路 |
| `+dashboard-block-list` | read | dws 仪表盘块是 chart(create/get/update/delete_chart),缺通用 block list,可对齐补 |
| `+dashboard-block-get` | read | dws get_chart 覆盖 chart 类块,缺通用 block get |
| `+dashboard-block-create` | write | dws create_chart 覆盖图表块,缺其他 block 类型的通用创建 |
| `+dashboard-block-update` | write | dws update_chart 覆盖图表块更新 |
| `+dashboard-block-delete` | high-risk-write | dws delete_chart 覆盖图表块删除 |
### sheets → sheet(14)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+sheet-hide` | write | dws update_sheet可能含hidden属性但未见独立hide命令,需确认 |
| `+sheet-unhide` | write | 同上,dws无独立unhide命令 |
| `+sheet-set-tab-color` | write | dws update_sheet或可设tab色但无独立命令 |
| `+sheet-show-gridline` | write | dws无网格线显隐命令 |
| `+sheet-hide-gridline` | write | dws无网格线显隐命令 |
| `+workbook-create` | write | dws有create_workspace_sheet但仅建空表,缺typed一步建表+填充+样式+partial回滚编排 |
| `+dim-hide` | write | dws update-dimension或含hidden但无独立hide命令 |
| `+dim-unhide` | write | 同上,dws无独立unhide命令 |
| `+dim-freeze` | write | dws update-dimension可能含frozen但无独立freeze命令 |
| `+cells-get` | read | dws range read存在但缺include样式/公式投影统一封装 |
| `+table-get` | read | dws缺typed table读回+列类型推断+多sheet编排,只有裸csv/range读 |
| `+table-put` | write | dws有append/set_cell_range但缺typed多sheet分块写+建缺失sheet+样式+partial回滚编排 |
| `+rows-resize` | write | dws update-dimension可调尺寸但无独立rows-resize+size/type互斥校验 |
| `+cols-resize` | write | dws update-dimension可调尺寸但无独立cols-resize+互斥校验 |
### apps → devapp(3)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+release-create` | write | dws 有 create_dev_app_version(开放平台版本)可类比,但妙搭 release 是低代码应用发布、语义与产物不同 |
| `+release-get` | read | dws 有 get_dev_app_version_detail 可类比但产品域(开放平台vs妙搭)不同 |
| `+release-list` | read | dws 有 list_dev_app_versions 可类比但无 status 枚举过滤且产品域不同 |
## 已建智能 shortcut(covered-smart,48)— 可继续升级保真度
- **im**: +chat-members-list +chat-list +messages-send +threads-messages-list
- **task**: +complete +assign +get-my-tasks +get-related-tasks
- **contact**: +search-user
- **calendar**: +agenda +create +update +freebusy +suggestion
- **doc (docs)**: +history-revert
- **drive**: +upload +search +inspect
- **mail**: +triage
- **minutes**: +upload +latest-minutes +action-items +transcript +minutes-search +detail +replace-batch
- **base**: +table-get +table-create +view-create +view-get-filter +view-set-filter +view-get-visible-fields +view-set-visible-fields +view-get-group +view-set-group +view-get-sort +view-set-sort +view-get-timebar +view-set-timebar +view-get-card +view-set-card +record-list +record-search +record-get +record-upsert +base-create +workflow-list +form-create +form-list +form-get +record-share-link-create
+243 -15
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-08-06T21:08:26.697858",
"count": 357,
"generated_at": "2026-08-10T17:25:46.245552",
"count": 378,
"results": [
{
"suite": "semantic",
@@ -2162,7 +2162,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"semantic_delta": "接受话题主消息 ID 并通过只读消息详情自动解析 conversation/thread,也接受显式 group + threadId(兼容 topicId);拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文,使用下层毫秒级 nextCursor 安全分页以避免同秒回复漏读,并支持全量结果 asc/desc;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"availability": "available"
},
{
@@ -3004,46 +3004,274 @@
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "minutes",
"command": "+action-items",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Resolves the latest task through strict itemList parsing and retrieves extracted Minutes actions without pretending to write Todo objects.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+apply-permission",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Maps view/download/edit intent to policy 4/3/2 and validates the permission-request response.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+detail",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Fans out selected artifacts, fully paginates transcript data, validates artifact-specific shapes and returns non-zero on partial reads.",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "minutes",
"command": "+download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Resolves real audio/video URLs, validates response shape and performs batch safe atomic local downloads with an explicit failure ledger.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+export-pack",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validates selected artifacts, safely publishes a no-clobber local directory and emits a manifest without signed URLs or credentials.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+latest",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validates the real itemList response and chooses latest only from an explicit comparable timestamp before reading details; +latest-minutes remains a compatibility alias.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+list-all",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "Stable projection of all accessible Minutes with strict itemList response validation.",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "minutes",
"command": "+list-mine",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "Stable projection of current-user Minutes with strict itemList response validation.",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "minutes",
"command": "+list-shared",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "Stable projection of shared Minutes with strict itemList response validation.",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "minutes",
"command": "+mindmap",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Creates the asynchronous mind-graph task exactly once and polls the explicit 0/1/2 taskStatus to success, failure or timeout.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+prepare-asr",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Reads the real personal hot-word set, computes deterministic add/delete differences, defaults to additive changes and verifies final state.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+record-pause",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Friendly recording-pause facade with explicit task UUID validation and confirmation.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+record-resume",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Friendly recording-resume facade with explicit task UUID validation and confirmation.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+record-start",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Friendly recording-create facade with explicit confirmation and stable session parameter.",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "minutes",
"command": "+record-stop",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Friendly recording-stop facade with explicit task UUID validation and confirmation.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+record-wrap-up",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Stops recording once, then boundedly waits for selected validated artifacts and preserves taskUuid recovery on partial completion.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+replace-batch",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validates multi-rule input, supports JSON/file/stdin and reports partial writes with stop/continue policy and non-zero failure.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+search",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validated itemList parsing, deterministic title filtering, bounded cursor pagination, de-duplication and completeness ledger; +minutes-search is a compatibility alias.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+share",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Grants semantic view/download/edit permissions per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+speaker-insights",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Requires a real async taskId, boundedly polls speaker summaries and returns task recovery handles when content is not ready.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+speaker-replace",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Fully paginates the transcript to preflight source speaker presence and verifies the nickname replacement after writing.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+summary",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Reads current summary, supports literal/file/stdin, preserves Markdown images, previews the change and verifies full read-back.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+transcript",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Resolves an explicit or latest task, follows every transcript cursor, de-duplicates paragraphs and publishes completeness.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+unshare",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Removes permission per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Reads the current title, previews the diff, avoids no-op writes and verifies the final title by read-back.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+upload",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Owns local file validation, create-PUT-complete polling, cancellation compensation and final task read-back verification.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+upload-and-analyze",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Completes local upload with compensation, waits for validated artifacts, and optionally orchestrates mind map and speaker insights without re-upload recovery hazards.",
"availability": "available"
},
{
"suite": "read",
+108
View File
@@ -0,0 +1,108 @@
# DWS 统一命令框架设计概要
> 状态:Framework core 已实现,dingtalk-dev/devapp 首批命令渐进接入中。本文定义框架能力、集成边界和首批 pilot 的发布纪律;其余产品命令迁移、Skill 更新和真实服务复验继续由后续 PR 独立完成。
## 1. 产品裁决
1. 不公开 `--output-contract`,也不增加任何等价别名。
2. Agent 继续只使用既有 `--format json`。
3. 每条 terminal command 在一个 release 中只有一个 active wire contract:已迁移命令直接使用统一结果,未迁移命令保持 legacy。
4. contract 不由用户参数、环境变量、会话能力协商或 Agent 选择。
5. 回滚是命令声明与发布行为,不改变消费者 argv。
6. 本 PR 只迁移完成命令级兼容审计的 dingtalk-dev/devapp pilot;其他命令路径、参数和输出保持不变。
## 2. 渐进迁移
内部状态机:
```text
legacy_only -> dual_validate -> unified_active -> unified_stable -> unified_only
```
- `legacy_only`:只构造、输出 legacy。
- `dual_validate`:业务只执行一次;外部仍逐字输出 legacy;同一内存结果 shadow-build 统一结果并严格校验。
- `unified_active`:`--format json` 直接返回统一结果信封,可按发布声明回退。
- `unified_stable`:完成真实 Agent 消费观察和兼容窗口。
- `unified_only`:清理仅服务 legacy 的产品 renderer。
状态是每条 terminal command 的内部发布元数据。Help、Skill、Agent Schema 不展示迁移状态,也不让消费者选择协议。
## 3. 统一结果
统一命令框架表达四类结果:
```text
success 请求完成且命令认为操作已完成
pending 请求被受理,但异步操作尚未终结
partial_failure 批量操作有成功项,也有失败或未知项
failure 请求或操作失败
```
JSON 基本形态:
```json
{
"ok": true,
"outcome": "success",
"data": {}
}
```
硬不变量:
```text
ok == (outcome in {success, pending})
process rc == 0 <=> ok == true
top-level error present <=> outcome == failure
one invocation emits exactly one primary result
```
框架负责 L1 request outcome 和 L2 operation outcome 的统一表达;L3 verification 必须由产品命令基于业务事实实现,框架不得自动推断 `changed/verified`。
## 4. 输出与错误纪律
- 统一 JSON primary result 写 stdout;stderr 只写诊断。普通命令不把
NDJSON 作为通用结果契约;持续事件流若需要逐事件输出,由 event 命令
自己声明专用流协议。
- 分页统一输出到信封 `meta.pagination`,并在命令 Schema 中作为与 `result`
同级的 `pagination` 能力声明;`result.data_schema` 只描述业务 data,不再
混入分页控制字段。
- 日志不得污染 stdout。
- `ok`、`retryable`、`dry_run` 等必须是 JSON boolean。
- 失败由框架根据 typed error 映射退出码;产品代码不能自报任意 rc。
- `partial_failure` 保留 `succeeded[]/failed[]/unknown[]`,使用非零 rc 7。
- `pending` 必须提供 operation id、state 和可执行的 `next_command`。
- `endpoint_exhausted` 只表示观察到当前 endpoint 分页耗尽;false 必须带 `next_token`,不得扩大成索引健康或业务数据完整。
- dry-run 是已经完成的无副作用预览,表达为 `success + dry_run:true`,不是 `pending`。
## 5. 重试与超时边界
- 框架只统一表达 `retryable`、`retry_after_seconds` 和 `execution_started`,不自动决定业务操作能否安全重放。
- 写调用的模糊失败、HTTP timeout 和异步等待预算属于 transport/产品集成范围,不在本 PR 改动。
- 产品迁移必须证明其重试声明与幂等性、安全等级一致。
## 6. 集成范围
- 产品命令通过 `corecmd.ResultInvoke` 构造 `CommandResult`,由 root 单一出口渲染。
- 首批 dingtalk-dev/devapp 命令用于验证原子命令与 shortcut 的接入缝;未进入 pilot 的 shortcut、长连接、批量写和异步任务各自需要独立集成 PR。框架 core 不替产品推断 success、pending、partial 或分页事实。
- 每条 terminal command 独立 rollout;不能整域一次切换,也不能通过 Agent 参数选择协议。
- 已有命令在进入 `unified_active` 前必须保留 legacy byte golden,并完成真实 Agent 语义扫描。
## 7. 对齐原则
- 对齐 Lark CLI:统一 envelope/emitter、typed error、partial、pending、分页窄语义和强类型结果。
- 对齐 GWS:机器结果稳定结构化、日志与数据分流、消费者不协商协议版本。
- DWS 保留差异:声明式 Agent Schema、安全门禁、静态命令与 shortcut 共存,以及四 outcome 模型。
## 8. 发布门禁
命令晋级 `unified_active` 前至少满足:
1. success/failure/dry-run golden;批量或异步命令另有 partial/pending golden。
2. 业务请求 exactly once;dual validation 不得二次调用服务端。
3. legacy 命令 stdout/stderr/rc 字节级回归不变。
4. Help、Schema 和全仓示例不存在协议选择参数。
5. `--format json` 输出单个合法统一结果文档,stdout 无日志污染。
6. typed error、进程 rc 与信封 `error.exit_code` 一致。
7. 安全声明、确认门禁与 dry-run 运行时行为同源。
8. Agent 语义扫描记录命令级迁移证据;发布回滚无需修改 Agent argv。
+84 -24
View File
@@ -38,6 +38,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/registry"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
@@ -74,7 +75,7 @@ var (
// newEventCommand returns the `event` parent command and all its subcommands.
// Wired into root.go's utilityCommands list.
func newEventCommand() *cobra.Command {
func newEventCommand(globalFlags ...*GlobalFlags) *cobra.Command {
// Product-level Agent routing Decl (migrated from selection/event.json
// products.event). Catalog assembly stamps provenance contract_final.
contract.RegisterProductDecl(contract.ProductDecl{
@@ -99,12 +100,12 @@ func newEventCommand() *cobra.Command {
RunE: func(c *cobra.Command, _ []string) error { return c.Help() },
}
cmd.AddCommand(
newEventListenIMCommand(),
newEventConsumeCommand(),
newEventListenIMCommand(globalFlags...),
newEventConsumeCommand(globalFlags...),
newEventListCommand(),
newEventSchemaCommand(),
newEventStatusCommand(),
newEventStopCommand(),
newEventStatusCommandWithFlags(globalFlags...),
newEventStopCommandWithFlags(globalFlags...),
newEventBusCommand(),
)
return cmd
@@ -114,7 +115,7 @@ func newEventCommand() *cobra.Command {
// event consume
// ─────────────────────────────────────────────────────────────────────
func newEventConsumeCommand() *cobra.Command {
func newEventConsumeCommand(globalFlags ...*GlobalFlags) *cobra.Command {
var (
eventTypes []string
filter string
@@ -170,6 +171,8 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
return err
}
if as == "user" {
personalOpts.ExplicitToken = eventExplicitToken(globalFlags)
personalOpts.ClientIDOverride = eventExplicitClientID(globalFlags)
personalOpts.EventKeys = dedupePersonalEventKeys(args)
personalOpts.EventKey = firstArg(personalOpts.EventKeys)
personalOpts.Flatten = flatten
@@ -563,6 +566,8 @@ func newEventBusCommand() *cobra.Command {
clientIDOverride string
idleTimeout time.Duration
sourceKindRaw string
runtimeTokenMode bool
identityHashFlag string
streamOpts eventStreamTicketOptions
)
cmd := &cobra.Command{
@@ -599,23 +604,45 @@ func newEventBusCommand() *cobra.Command {
sourceKind = dwsevent.SourceKindAppStream
}
if sourceKind == dwsevent.SourceKindPersonalStream {
identity, err := eventResolvePersonal(ctx, configDir, streamOpts.SourceID)
if err != nil {
return failEarly(fmt.Errorf("event _bus: %w", err))
var (
identity personal.Identity
identityHash string
)
if runtimeTokenMode {
identityHash = strings.TrimSpace(identityHashFlag)
if !validPersonalIdentityHash(identityHash) {
return failEarly(errors.New("event _bus: --identity-hash must be a 16-character hexadecimal identity hash in runtime token mode"))
}
if strings.TrimSpace(clientIDOverride) == "" {
return failEarly(errors.New("event _bus: --client-id is required in runtime token mode"))
}
identity = personal.Identity{
ClientID: strings.TrimSpace(clientIDOverride),
SourceID: personalEventStreamSourceID(streamOpts.SourceID),
}
} else {
var err error
identity, err = eventResolvePersonal(ctx, configDir, streamOpts.SourceID)
if err != nil {
return failEarly(fmt.Errorf("event _bus: %w", err))
}
if clientIDOverride != "" {
identity.ClientID = clientIDOverride
}
identityHash = dwsevent.IdentityHash(identity.Key())
}
if clientIDOverride != "" {
identity.ClientID = clientIDOverride
}
identityHash := dwsevent.IdentityHash(identity.Key())
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
endpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
credentialBroker := newPersonalCredentialBroker(configDir, runtimeTokenMode, runtimeTokenMode)
src, err := eventNewPersonalSource(ctx, personalStreamSourceOptions{
ConfigDir: configDir,
Identity: identity,
TicketMode: streamOpts.Mode,
TicketURL: streamOpts.TicketURL,
ClientIDOverride: clientIDOverride,
CredentialBroker: credentialBroker,
RuntimeTokenMode: runtimeTokenMode,
})
if err != nil {
return failEarly(err)
@@ -628,17 +655,18 @@ func newEventBusCommand() *cobra.Command {
}
}
busCfg := bus.Config{
WorkDir: workDir,
IPCEndpoint: endpoint,
ClientID: identity.ClientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
Source: src,
IdleTimeout: idleTimeout,
ReadyPipe: readyPipe,
Logger: slog.Default(),
WorkDir: workDir,
IPCEndpoint: endpoint,
ClientID: identity.ClientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
Source: src,
IdleTimeout: idleTimeout,
ReadyPipe: readyPipe,
Logger: slog.Default(),
CredentialBroker: credentialBroker,
}
bus.ApplyEnvTuning(&busCfg)
return eventBusRun(ctx, busCfg)
@@ -698,12 +726,18 @@ func newEventBusCommand() *cobra.Command {
"exit after this long with zero consumers (0 = disabled)")
cmd.Flags().StringVar(&sourceKindRaw, "source-kind", string(dwsevent.SourceKindAppStream),
"event source kind: app_stream|personal_stream")
cmd.Flags().BoolVar(&runtimeTokenMode, "runtime-token-mode", false,
"use an owner-injected in-memory runtime credential")
cmd.Flags().StringVar(&identityHashFlag, "identity-hash", "",
"pre-resolved non-sensitive personal identity hash")
cmd.Flags().StringVar(&streamOpts.Mode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")),
"用户 Stream 建联模式:空=SDK app credential;normal/custom=portal 取票")
cmd.Flags().StringVar(&streamOpts.SourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
"用户 Stream sourceId;personal_stream 开源版默认 open")
cmd.Flags().StringVar(&streamOpts.TicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")),
"用户 Stream 取票 URL;personal_stream 默认由 MCP base URL 派生")
_ = cmd.Flags().MarkHidden("runtime-token-mode")
_ = cmd.Flags().MarkHidden("identity-hash")
return cmd
}
@@ -822,6 +856,10 @@ func newEventListCommand() *cobra.Command {
// ─────────────────────────────────────────────────────────────────────
func newEventStatusCommand() *cobra.Command {
return newEventStatusCommandWithFlags()
}
func newEventStatusCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command {
var (
all bool
allEditions bool
@@ -847,6 +885,8 @@ func newEventStatusCommand() *cobra.Command {
return fmt.Errorf("event status: %w", err)
}
personalOpts.Format = formatRaw
personalOpts.ExplicitToken = eventExplicitToken(globalFlags)
personalOpts.ClientIDOverride = eventExplicitClientID(globalFlags)
return eventRunPersonalStatus(c, personalOpts)
}
if err := rejectChangedFlags(c, "user", "event", "status", "subscribe-id", "personal-event-base-url", "stream-source-id"); err != nil {
@@ -1138,6 +1178,10 @@ func renderStatusBlock(w io.Writer, qs busctl.EntryStatus) {
}
func newEventStopCommand() *cobra.Command {
return newEventStopCommandWithFlags()
}
func newEventStopCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command {
var asIdentity string
var opts personalStopOptions
cmd := &cobra.Command{
@@ -1158,6 +1202,8 @@ func newEventStopCommand() *cobra.Command {
}
if as == "user" {
opts.SubscribeID = firstArg(args)
opts.ExplicitToken = eventExplicitToken(globalFlags)
opts.ClientIDOverride = eventExplicitClientID(globalFlags)
if eventStopDryRun(c) {
return writeEventStopDryRun(c, as, opts)
}
@@ -1261,6 +1307,20 @@ func eventStopDryRun(cmd *cobra.Command) bool {
return value
}
func eventExplicitToken(globalFlags []*GlobalFlags) string {
if len(globalFlags) == 0 || globalFlags[0] == nil {
return ""
}
return strings.TrimSpace(globalFlags[0].Token)
}
func eventExplicitClientID(globalFlags []*GlobalFlags) string {
if len(globalFlags) == 0 || globalFlags[0] == nil {
return ""
}
return strings.TrimSpace(globalFlags[0].ClientID)
}
func writeEventStopDryRun(cmd *cobra.Command, identity string, opts personalStopOptions) error {
payload := map[string]any{
"dry_run": true,
+3 -1
View File
@@ -65,7 +65,7 @@ func (eventTargetReader) CallMCPData(product, tool string, params map[string]any
var eventListenIMReader = func() targetresolver.Reader { return eventTargetReader{} }
func newEventListenIMCommand() *cobra.Command {
func newEventListenIMCommand(globalFlags ...*GlobalFlags) *cobra.Command {
var opts listenIMOptions
cmd := &cobra.Command{
Use: "+listen-im",
@@ -91,6 +91,8 @@ func newEventListenIMCommand() *cobra.Command {
StreamTicketMode: opts.StreamTicketMode,
StreamTicketURL: opts.StreamTicketURL,
StreamSourceID: opts.StreamSourceID,
ExplicitToken: eventExplicitToken(globalFlags),
ClientIDOverride: eventExplicitClientID(globalFlags),
Common: commonConsumeOptions{
FormatRaw: "ndjson",
MaxEvents: opts.MaxEvents,
+20
View File
@@ -28,6 +28,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
@@ -184,6 +185,25 @@ func (r *personalSubscriptionAttemptReservation) completeSuccess() error {
return nil
}
// releaseRuntimeTokenFailure releases the in-flight claim without recording a
// cross-invocation hold. A host may supply a fresh token on the very next
// command, which must be allowed to retry immediately.
func (r *personalSubscriptionAttemptReservation) releaseRuntimeTokenFailure() error {
if r == nil {
return runtimecred.ErrRuntimeTokenRejected
}
if r.store == nil || r.claim == nil {
return personalSubscriptionGuardError(errors.Join(
runtimecred.ErrRuntimeTokenRejected,
errors.New("personal event: subscription attempt reservation is incomplete"),
))
}
if err := r.store.Release(r.claim); err != nil {
return personalSubscriptionGuardError(errors.Join(runtimecred.ErrRuntimeTokenRejected, err))
}
return runtimecred.ErrRuntimeTokenRejected
}
func (r *personalSubscriptionAttemptReservation) completeFailure(
ctx context.Context,
failedIndex int,
+432 -47
View File
@@ -14,6 +14,7 @@
package app
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
@@ -40,6 +41,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
@@ -82,6 +84,8 @@ type personalConsumeOptions struct {
StreamTicketMode string
StreamTicketURL string
StreamSourceID string
ExplicitToken string
ClientIDOverride string
}
type personalListOptions struct {
@@ -92,19 +96,23 @@ type personalListOptions struct {
}
type personalStatusOptions struct {
EventKey string
Status string
SubscribeID string
Format string
ControlBaseURL string
StreamSourceID string
EventKey string
Status string
SubscribeID string
Format string
ControlBaseURL string
StreamSourceID string
ExplicitToken string
ClientIDOverride string
}
type personalStopOptions struct {
SubscribeID string
All bool
ControlBaseURL string
StreamSourceID string
SubscribeID string
All bool
ControlBaseURL string
StreamSourceID string
ExplicitToken string
ClientIDOverride string
}
type personalStreamSourceOptions struct {
@@ -113,6 +121,8 @@ type personalStreamSourceOptions struct {
TicketMode string
TicketURL string
ClientIDOverride string
CredentialBroker *runtimecred.Broker
RuntimeTokenMode bool
}
var (
@@ -142,10 +152,19 @@ var (
personalResolveAuxiliaryAccessToken = ResolveAuxiliaryAccessToken
personalForceRefreshRejectedToken = forceRefreshRejectedAccessToken
personalLoadTokenData = authpkg.LoadTokenData
personalLoadProfiles = authpkg.LoadProfiles
personalClientID = authpkg.ClientID
personalRuntimeEventClientID = runtimePersonalEventClientID
personalResolveAppCredentialsStrict = authpkg.ResolveAppCredentialsStrict
)
func runtimePersonalEventClientID() string {
if clientID := strings.TrimSpace(edition.Get().AuthClientID); clientID != "" {
return clientID
}
return strings.TrimSpace(os.Getenv("DWS_CLIENT_ID"))
}
func newEventSchemaCommand() *cobra.Command {
var asIdentity string
var formatRaw string
@@ -283,7 +302,7 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
projector := personalEventProjector(opts.DebugRawEvents, opts.Flatten)
configDir := defaultConfigDir()
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
@@ -291,13 +310,24 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
spawnProfileSelector := personalBusProfileSelector(configDir, identity)
spawnProfileSelector := ""
if strings.TrimSpace(opts.ExplicitToken) == "" {
spawnProfileSelector = personalBusProfileSelector(configDir, identity)
}
spawnArgs := personalBusSpawnArgsForToken(
identity,
identityHash,
opts.StreamTicketMode,
opts.StreamTicketURL,
spawnProfileSelector,
opts.ExplicitToken,
)
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
if err != nil {
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken)
if opts.Common.DryRun {
if strings.TrimSpace(opts.SubscribeID) == "" {
if err := validatePersonalSubscriptionOptions(opts); err != nil {
@@ -314,7 +344,7 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector),
SpawnExtraArgs: personalBusSpawnArgsForToken(identity, identityHash, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector, opts.ExplicitToken),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
@@ -341,7 +371,8 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector),
SpawnExtraArgs: spawnArgs,
RuntimeToken: strings.TrimSpace(opts.ExplicitToken),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
@@ -370,13 +401,25 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
}
}
var foregroundSource *source.PersonalSource
var (
foregroundSource *source.PersonalSource
foregroundBroker *runtimecred.Broker
)
if opts.Common.Foreground {
explicitToken := strings.TrimSpace(opts.ExplicitToken)
foregroundBroker = newPersonalCredentialBroker(configDir, explicitToken != "", false)
if explicitToken != "" {
if _, err := foregroundBroker.Update(0, explicitToken); err != nil {
return personalSubscriptionValidationError(err)
}
}
foregroundSource, err = personalNewStreamSource(ctx, personalStreamSourceOptions{
ConfigDir: configDir,
Identity: identity,
TicketMode: opts.StreamTicketMode,
TicketURL: opts.StreamTicketURL,
ConfigDir: configDir,
Identity: identity,
TicketMode: opts.StreamTicketMode,
TicketURL: opts.StreamTicketURL,
CredentialBroker: foregroundBroker,
RuntimeTokenMode: explicitToken != "",
})
if err != nil {
return personalSubscriptionValidationError(err)
@@ -398,6 +441,10 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
}
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts)
if err != nil {
if strings.TrimSpace(opts.ExplicitToken) != "" && personalRuntimeTokenControlRejection(err) {
err = attempt.releaseRuntimeTokenFailure()
return fmt.Errorf("event consume --as user: %w", err)
}
err = attempt.completeFailure(ctx, 0, 0, err, nil)
return fmt.Errorf("event consume --as user: %w", err)
}
@@ -476,14 +523,15 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
}
if opts.Common.Foreground {
busCfg := bus.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
Source: foregroundSource,
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
Source: foregroundSource,
CredentialBroker: foregroundBroker,
}
bus.ApplyEnvTuning(&busCfg)
return personalBusRun(ctx, busCfg)
@@ -517,7 +565,7 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
ctx := c.Context()
configDir := defaultConfigDir()
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
@@ -525,7 +573,10 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
spawnProfileSelector := personalBusProfileSelector(configDir, identity)
spawnProfileSelector := ""
if strings.TrimSpace(opts.ExplicitToken) == "" {
spawnProfileSelector = personalBusProfileSelector(configDir, identity)
}
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
if err != nil {
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
@@ -534,7 +585,7 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector),
SpawnExtraArgs: personalBusSpawnArgsForToken(identity, identityHash, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector, opts.ExplicitToken),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
@@ -560,8 +611,9 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
printPersonalMultiDryRun(c.ErrOrStderr(), baseCfg, plans)
return nil
}
baseCfg.RuntimeToken = strings.TrimSpace(opts.ExplicitToken)
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken)
attempt, err := reservePersonalSubscriptionAttempts(
workDir,
client,
@@ -598,6 +650,10 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
if personalSubscriptionCanceled(ctx, cause) {
cleanupCtx = ctx
}
if strings.TrimSpace(opts.ExplicitToken) != "" && personalRuntimeTokenControlRejection(cause) {
cleanup(cleanupCtx)
return attempt.releaseRuntimeTokenFailure()
}
completed := attempt.completeFailure(ctx, failedIndex, succeededCount, cause, override)
// Persist the hold (or release a canceled claim) before any potentially
// slow remote rollback. Otherwise the attempt lease can expire while
@@ -985,7 +1041,7 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
return err
}
configDir := defaultConfigDir()
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride)
if err != nil {
return fmt.Errorf("event status --as user: %w", err)
}
@@ -1017,7 +1073,7 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
if status == "" || status == "all" {
status = ""
}
subs, err := personalListSubscriptions(newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{
subs, err := personalListSubscriptions(newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken), ctx, personal.ListOptions{
Status: status,
EventKey: opts.EventKey,
SubscribeID: opts.SubscribeID,
@@ -1038,6 +1094,15 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
return nil
}
func personalRuntimeTokenControlRejection(err error) bool {
var apiErr *personal.APIError
if !errors.As(err, &apiErr) || apiErr == nil {
return false
}
return apiErr.HTTPStatus == http.StatusUnauthorized ||
strings.EqualFold(strings.TrimSpace(apiErr.Code), "RUNTIME_TOKEN_REJECTED")
}
func ensurePublicPersonalEvent(eventKey string) error {
eventKey = strings.TrimSpace(eventKey)
if eventKey == "" {
@@ -1120,7 +1185,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
}
configDir := defaultConfigDir()
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride)
if err != nil {
return fmt.Errorf("event stop --as user: %w", err)
}
@@ -1132,7 +1197,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
if err != nil {
return fmt.Errorf("event stop --as user: %w", err)
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken)
for _, id := range subscribeIDs {
if err := personalDeleteSubscription(client, ctx, id); err != nil {
return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err)
@@ -1248,6 +1313,138 @@ func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, bu
fmt.Fprintf(w, "cancelled %d personal subscription(s); %s\n", len(subscribeIDs), busState)
}
func resolvePersonalEventIdentityForToken(ctx context.Context, configDir, sourceIDOverride, explicitToken string, clientIDOverrides ...string) (personal.Identity, error) {
explicitToken = strings.TrimSpace(explicitToken)
if explicitToken == "" {
return personalResolveEventIdentity(ctx, configDir, sourceIDOverride)
}
clientIDOverride := ""
if len(clientIDOverrides) > 0 {
clientIDOverride = strings.TrimSpace(clientIDOverrides[0])
}
return resolvePersonalEventIdentityWithToken(ctx, configDir, sourceIDOverride, explicitToken, clientIDOverride)
}
// resolvePersonalEventIdentityWithToken resolves only non-sensitive identity
// metadata around a caller-supplied bearer token. It intentionally does not
// call LoadTokenData or any refresh-capable token resolver: an explicit root
// --token must never be replaced with, persisted into, or used to refresh a
// local OAuth profile.
func resolvePersonalEventIdentityWithToken(ctx context.Context, configDir, sourceIDOverride, explicitToken string, clientIDOverrides ...string) (personal.Identity, error) {
explicitToken = strings.TrimSpace(explicitToken)
if explicitToken == "" {
return resolvePersonalEventIdentity(ctx, configDir, sourceIDOverride)
}
if strings.Contains(strings.TrimSpace(authpkg.RuntimeProfile()), ",") {
return personal.Identity{}, fmt.Errorf("personal events require exactly one --profile")
}
corpID := resolveRuntimeDefault(ctx, "$corpId")
userID := resolveRuntimeDefault(ctx, "$currentUserId")
clientID := ""
if len(clientIDOverrides) > 0 {
clientID = strings.TrimSpace(clientIDOverrides[0])
}
if clientID == "" {
// An edition hook or explicit environment value is runtime identity,
// not persisted app state. Resolve it before profiles.json so a complete
// host context never depends on local OAuth metadata health.
clientID = strings.TrimSpace(personalRuntimeEventClientID())
}
explicitProfile := strings.TrimSpace(authpkg.RuntimeProfile()) != ""
if explicitProfile || corpID == "" || userID == "" || clientID == "" {
profile, err := personalEventProfileMetadata(configDir)
if err != nil {
// A user-selected --profile remains a strict contract. Without an
// explicit selector, profiles.json is optional metadata for a
// host-managed bearer: malformed or stale persisted state must not
// override complete runtime defaults or prevent the later global
// client-id fallback.
if explicitProfile {
return personal.Identity{}, fmt.Errorf("load OAuth identity metadata: %w", err)
}
profile = nil
}
if profile != nil {
if corpID == "" {
corpID = strings.TrimSpace(profile.CorpID)
}
if userID == "" {
userID = strings.TrimSpace(profile.UserID)
}
if clientID == "" {
clientID = strings.TrimSpace(profile.ClientID)
}
}
}
if clientID == "" {
// Persisted/global app credentials are only a fallback after the
// selected profile, so an old app config cannot override profile.ClientID.
clientID = strings.TrimSpace(personalClientID())
}
if clientID == "" {
if id, _, _, _, resolveErr := personalResolveAppCredentialsStrict(configDir); resolveErr == nil {
clientID = strings.TrimSpace(id)
}
}
if clientID == "" {
return personal.Identity{}, fmt.Errorf("cannot resolve OAuth client_id for personal events")
}
sourceID := strings.TrimSpace(sourceIDOverride)
if sourceID == "" {
sourceID = personalEventStreamSourceID("")
}
localSubject := ""
if corpID == "" || userID == "" {
localSubject = personalTokenSubject("access", explicitToken)
}
return personal.Identity{
LocalSubject: localSubject,
CorpID: corpID,
UserID: userID,
ClientID: clientID,
SourceID: sourceID,
}, nil
}
func personalEventProfileMetadata(configDir string) (*authpkg.Profile, error) {
cfg, err := personalLoadProfiles(configDir)
if err != nil {
return nil, err
}
selector := strings.TrimSpace(authpkg.RuntimeProfile())
explicitSelector := selector != ""
if strings.Contains(selector, ",") {
return nil, fmt.Errorf("personal events require exactly one --profile")
}
if cfg == nil || len(cfg.Profiles) == 0 {
if explicitSelector {
return nil, fmt.Errorf("profile %q not found", selector)
}
return nil, nil
}
if selector == "" {
selector = strings.TrimSpace(cfg.CurrentProfile)
}
if selector == "" {
return nil, nil
}
profile, err := selectPersonalEventProfileMetadata(cfg, selector, make(map[string]struct{}))
if err != nil && !explicitSelector {
// A stale persisted CurrentProfile must not make a host-provided bearer
// unusable. Runtime defaults and the one-way local subject are sufficient
// to isolate the event bus without consulting local OAuth credentials.
return nil, nil
}
return profile, err
}
func selectPersonalEventProfileMetadata(cfg *authpkg.ProfilesConfig, selector string, visited map[string]struct{}) (*authpkg.Profile, error) {
_ = visited // retained for the focused compatibility seam used by app tests.
return authpkg.ResolveProfileMetadata(cfg, strings.TrimSpace(selector))
}
func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceIDOverride string) (personal.Identity, error) {
accessToken, err := personalResolveAuxiliaryAccessToken(ctx, configDir, "")
if err != nil {
@@ -1302,7 +1499,11 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
}, nil
}
func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity) *personal.Client {
func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity, explicitTokens ...string) *personal.Client {
explicitToken := ""
if len(explicitTokens) > 0 {
explicitToken = strings.TrimSpace(explicitTokens[0])
}
identity.AccessToken = ""
client := personal.NewClient(baseURL, identity)
version := strings.TrimSpace(RawVersion())
@@ -1311,12 +1512,146 @@ func newPersonalEventControlClient(configDir, baseURL string, identity personal.
}
client.ClientVersion = version
client.UserAgent = "dws-cli/" + version
client.AccessTokenProvider = func(ctx context.Context) (string, error) {
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
if explicitToken != "" {
client.AccessTokenProvider = func(context.Context) (string, error) { return explicitToken, nil }
client.HTTPClient.Transport = runtimeTokenControlTransport{base: http.DefaultTransport, token: explicitToken}
client.HTTPClient.CheckRedirect = runtimeTokenRedirectPolicy
} else {
client.AccessTokenProvider = func(ctx context.Context) (string, error) {
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
}
}
return client
}
// runtimeTokenRedirectPolicy prevents Go's redirect machinery from copying
// DWS's custom x-user-access-token header to another authority. Returning
// ErrUseLastResponse keeps the 3xx response available to the caller without a
// url.Error that could echo an attacker-controlled Location value.
func runtimeTokenRedirectPolicy(req *http.Request, via []*http.Request) error {
if len(via) == 0 || req == nil || req.URL == nil || via[0] == nil || via[0].URL == nil {
return http.ErrUseLastResponse
}
origin := via[0].URL
if !strings.EqualFold(strings.TrimSpace(req.URL.Host), strings.TrimSpace(origin.Host)) {
return http.ErrUseLastResponse
}
if strings.EqualFold(origin.Scheme, "https") && !strings.EqualFold(req.URL.Scheme, "https") {
return http.ErrUseLastResponse
}
return nil
}
const runtimeTokenControlErrorBody = `{"code":"RUNTIME_TOKEN_REJECTED","message":"event runtime token was rejected; retry with a fresh host credential"}`
// runtimeTokenControlTransport scrubs an explicit bearer from every response
// body and diagnostic header before the control client decodes or logs it. A
// 401 is replaced with a fixed rejection envelope so untrusted response text
// can never escape through stderr or debug logs.
type runtimeTokenControlTransport struct {
base http.RoundTripper
token string
}
func (t runtimeTokenControlTransport) RoundTrip(req *http.Request) (*http.Response, error) {
base := t.base
if base == nil {
base = http.DefaultTransport
}
resp, err := base.RoundTrip(req)
if err != nil {
if token := strings.TrimSpace(t.token); token != "" && strings.Contains(err.Error(), token) {
return nil, errors.New("personal event: runtime-token control request failed")
}
return nil, err
}
if resp == nil {
return resp, err
}
token := strings.TrimSpace(t.token)
for key, values := range resp.Header {
for i := range values {
if token != "" {
values[i] = strings.ReplaceAll(values[i], token, "<redacted-runtime-token>")
}
}
resp.Header[key] = values
}
var responseBody []byte
if resp.Body != nil {
responseBody, err = io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
_ = resp.Body.Close()
if err != nil {
return nil, errors.New("personal event: read runtime-token control response")
}
}
if resp.StatusCode == http.StatusUnauthorized {
responseBody = []byte(runtimeTokenControlErrorBody)
} else if token != "" {
responseBody = redactRuntimeTokenResponseBody(responseBody, token)
}
resp.Body = io.NopCloser(bytes.NewReader(responseBody))
resp.ContentLength = int64(len(responseBody))
if resp.Header == nil {
resp.Header = make(http.Header)
}
resp.Header.Set("Content-Type", "application/json")
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(responseBody)))
return resp, nil
}
func redactRuntimeTokenResponseBody(data []byte, token string) []byte {
token = strings.TrimSpace(token)
if len(data) == 0 || token == "" {
return data
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
var decoded any
if err := decoder.Decode(&decoded); err == nil {
var trailing any
if trailingErr := decoder.Decode(&trailing); errors.Is(trailingErr, io.EOF) {
if redacted, changed := redactRuntimeTokenJSONValue(decoded, token); changed {
if encoded, marshalErr := json.Marshal(redacted); marshalErr == nil {
return encoded
}
}
}
}
return bytes.ReplaceAll(data, []byte(token), []byte("<redacted-runtime-token>"))
}
func redactRuntimeTokenJSONValue(value any, token string) (any, bool) {
switch typed := value.(type) {
case string:
redacted := strings.ReplaceAll(typed, token, "<redacted-runtime-token>")
return redacted, redacted != typed
case []any:
changed := false
for i := range typed {
var itemChanged bool
typed[i], itemChanged = redactRuntimeTokenJSONValue(typed[i], token)
changed = changed || itemChanged
}
return typed, changed
case map[string]any:
changed := false
redactedMap := make(map[string]any, len(typed))
for key, item := range typed {
redactedKey := strings.ReplaceAll(key, token, "<redacted-runtime-token>")
redacted, itemChanged := redactRuntimeTokenJSONValue(item, token)
redactedMap[redactedKey] = redacted
changed = changed || itemChanged || redactedKey != key
}
if !changed {
return typed, false
}
return redactedMap, true
default:
return value, false
}
}
func personalTokenSubject(kind, token string) string {
token = strings.TrimSpace(token)
if token == "" {
@@ -1326,6 +1661,15 @@ func personalTokenSubject(kind, token string) string {
return strings.TrimSpace(kind) + ":" + hex.EncodeToString(sum[:])
}
func validPersonalIdentityHash(value string) bool {
value = strings.TrimSpace(value)
if len(value) != 16 {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
func resolveRuntimeDefault(ctx context.Context, key string) string {
if fnMap := edition.Get().RuntimeDefaults; fnMap != nil {
if fn := fnMap()[key]; fn != nil {
@@ -1363,20 +1707,42 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio
}
clientSecret = secret
}
credentialBroker := opts.CredentialBroker
if credentialBroker == nil {
credentialBroker = newPersonalCredentialBroker(opts.ConfigDir, false, false)
}
httpClient := &http.Client{Timeout: 30 * time.Second}
if opts.RuntimeTokenMode {
httpClient.CheckRedirect = runtimeTokenRedirectPolicy
}
_ = ctx
return source.NewPersonal(source.PersonalConfig{
AccessTokenProvider: func(ctx context.Context) (string, error) {
return personalResolveAuxiliaryAccessToken(ctx, opts.ConfigDir, "")
return credentialBroker.Resolve(ctx)
},
ForceRefreshToken: func(ctx context.Context, rejectedToken string) (string, error) {
return personalForceRefreshRejectedToken(ctx, opts.ConfigDir, rejectedToken)
return credentialBroker.RefreshRejected(ctx, rejectedToken)
},
ClassifyRetryReject: credentialBroker.ClassifyRejectedAfterRetry,
ClientID: clientID,
ClientSecret: clientSecret,
SourceID: opts.Identity.SourceID,
TicketURL: ticketURL,
TicketMode: mode,
HTTPClient: httpClient,
})
}
func newPersonalCredentialBroker(configDir string, requireSeed, requireActivation bool) *runtimecred.Broker {
return runtimecred.New(runtimecred.Config{
RequireSeed: requireSeed,
RequireActivation: requireActivation,
LocalResolve: func(ctx context.Context) (string, error) {
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
},
LocalRefresh: func(ctx context.Context, rejectedToken string) (string, error) {
return personalForceRefreshRejectedToken(ctx, configDir, rejectedToken)
},
ClientID: clientID,
ClientSecret: clientSecret,
SourceID: opts.Identity.SourceID,
TicketURL: ticketURL,
TicketMode: mode,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
})
}
@@ -1441,6 +1807,25 @@ func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL stri
return args
}
func personalBusSpawnArgsForToken(identity personal.Identity, identityHash, ticketMode, ticketURL, profileSelector, explicitToken string) []string {
if strings.TrimSpace(explicitToken) == "" {
return personalBusSpawnArgs(identity, ticketMode, ticketURL, profileSelector)
}
args := []string{
"--source-kind", string(dwsevent.SourceKindPersonalStream),
"--runtime-token-mode",
"--identity-hash", strings.TrimSpace(identityHash),
"--stream-source-id", strings.TrimSpace(identity.SourceID),
}
if strings.TrimSpace(ticketMode) != "" {
args = append(args, "--stream-ticket-mode", strings.TrimSpace(ticketMode))
}
if strings.TrimSpace(ticketURL) != "" {
args = append(args, "--stream-ticket-url", strings.TrimSpace(ticketURL))
}
return args
}
func personalEventTypes(eventKey string, explicit []string) []string {
if len(explicit) > 0 {
return explicit
@@ -0,0 +1,396 @@
// 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.
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"testing"
"time"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
const (
runtimeTokenDetachedChildEnv = "DWS_EVENT_RUNTIME_TOKEN_E2E_CHILD"
runtimeTokenDetachedWorkDirEnv = "DWS_EVENT_RUNTIME_TOKEN_E2E_WORKDIR"
runtimeTokenDetachedEndpointEnv = "DWS_EVENT_RUNTIME_TOKEN_E2E_ENDPOINT"
runtimeTokenDetachedEvidenceEnv = "DWS_EVENT_RUNTIME_TOKEN_E2E_EVIDENCE"
runtimeTokenDetachedCanaryA = "dws-runtime-e2e-A-9f34c8d10b7e"
runtimeTokenDetachedCanaryB = "dws-runtime-e2e-B-2ad761e5c490"
runtimeTokenDetachedClientID = "runtime-e2e-client"
runtimeTokenDetachedIdentityHash = "90abcdef12345678"
runtimeTokenDetachedSourceID = "runtime-e2e-source"
)
// runRuntimeTokenDetachedE2EChild is called at the very start of TestMain.
// busctl.Spawn executes this test binary with production-style `event _bus`
// arguments; the env marker lets the child run a real bus daemon before the Go
// test runner attempts to parse those CLI arguments.
func runRuntimeTokenDetachedE2EChild() (int, bool) {
if os.Getenv(runtimeTokenDetachedChildEnv) != "1" {
return 0, false
}
workDir := strings.TrimSpace(os.Getenv(runtimeTokenDetachedWorkDirEnv))
endpoint := strings.TrimSpace(os.Getenv(runtimeTokenDetachedEndpointEnv))
evidence := strings.TrimSpace(os.Getenv(runtimeTokenDetachedEvidenceEnv))
if workDir == "" || endpoint == "" || evidence == "" {
return 91, true
}
argvClean := !runtimeTokenDetachedContainsCanary(strings.Join(os.Args, "\x00"))
envClean := !runtimeTokenDetachedContainsCanary(strings.Join(os.Environ(), "\x00"))
if err := appendRuntimeTokenDetachedEvidence(evidence,
fmt.Sprintf("child_start argv_clean=%t env_clean=%t", argvClean, envClean)); err != nil {
return 92, true
}
if !argvClean || !envClean {
return 93, true
}
logFile, err := os.OpenFile(filepath.Join(workDir, "bus.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return 94, true
}
defer logFile.Close()
broker := runtimecred.New(runtimecred.Config{RequireSeed: true, RequireActivation: true})
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
err = bus.Run(ctx, bus.Config{
WorkDir: workDir,
IPCEndpoint: endpoint,
ClientID: runtimeTokenDetachedClientID,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: runtimeTokenDetachedIdentityHash,
SourceID: runtimeTokenDetachedSourceID,
Edition: "open",
SDKVersion: "runtime-e2e",
Source: &runtimeTokenDetachedSource{broker: broker, evidence: evidence},
CredentialBroker: broker,
ReadyPipe: busctl.ReadyFDFromEnv(),
Logger: slog.New(slog.NewTextHandler(logFile, nil)),
})
if err != nil && !errors.Is(err, context.Canceled) {
_ = appendRuntimeTokenDetachedEvidence(evidence, "bus_exit clean=false")
return 95, true
}
_ = appendRuntimeTokenDetachedEvidence(evidence, "bus_exit clean=true")
return 0, true
}
type runtimeTokenDetachedSource struct {
broker *runtimecred.Broker
evidence string
}
// Start models the credential-sensitive part of a reconnecting Stream source
// without network access. It resolves A for the first connection, waits until a
// second consumer rotates the broker to B, then exercises the exact 401 path:
// RefreshRejected(A) must return B and must not fall back to local OAuth.
func (s *runtimeTokenDetachedSource) Start(ctx context.Context, _ dwsevent.EmitFn) error {
first, err := s.broker.Resolve(ctx)
if err != nil {
return errors.New("runtime e2e: initial credential unavailable")
}
if first != runtimeTokenDetachedCanaryA || s.broker.Generation() != 1 {
return errors.New("runtime e2e: initial credential mismatch")
}
if err := appendRuntimeTokenDetachedEvidence(s.evidence, "resolved_a=true generation=1"); err != nil {
return errors.New("runtime e2e: record initial connection")
}
ticker := time.NewTicker(5 * time.Millisecond)
defer ticker.Stop()
for s.broker.Generation() < 2 {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
rotated, err := s.broker.RefreshRejected(ctx, first)
if err != nil || rotated != runtimeTokenDetachedCanaryB {
return errors.New("runtime e2e: rotated credential unavailable")
}
if err := appendRuntimeTokenDetachedEvidence(s.evidence, "rejected_a=true resolved_b=true reconnect=true generation=2"); err != nil {
return errors.New("runtime e2e: record reconnect")
}
<-ctx.Done()
return ctx.Err()
}
func appendRuntimeTokenDetachedEvidence(path, line string) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer f.Close()
_, err = fmt.Fprintln(f, line)
return err
}
func runtimeTokenDetachedContainsCanary(value string) bool {
return strings.Contains(value, runtimeTokenDetachedCanaryA) ||
strings.Contains(value, runtimeTokenDetachedCanaryB)
}
func TestCrossPlatformCoverageUnixDetachedRuntimeTokenLifecycleAndCanaryLeakScan(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("real detached-process lifecycle is Unix-only; Windows named-pipe code is cross-compiled separately")
}
root, err := os.MkdirTemp("/tmp", "dws-runtime-token-e2e-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(root) })
workDir := filepath.Join(root, "events", "open", string(dwsevent.SourceKindPersonalStream), runtimeTokenDetachedIdentityHash)
if err := os.MkdirAll(workDir, 0o700); err != nil {
t.Fatal(err)
}
endpoint := dwsevent.IPCEndpoint(workDir, "open", dwsevent.SourceKindPersonalStream, runtimeTokenDetachedIdentityHash)
evidencePath := filepath.Join(workDir, "runtime-e2e.evidence")
identity := personal.Identity{
ClientID: runtimeTokenDetachedClientID,
SourceID: runtimeTokenDetachedSourceID,
CorpID: "runtime-e2e-corp",
UserID: "runtime-e2e-user",
}
spawnArgs := personalBusSpawnArgsForToken(identity, runtimeTokenDetachedIdentityHash, "", "", "corp:user", runtimeTokenDetachedCanaryA)
assertRuntimeTokenDetachedClean(t, "spawn argv", []byte(strings.Join(spawnArgs, "\x00")))
childEnv := append([]string{}, os.Environ()...)
childEnv = append(childEnv,
runtimeTokenDetachedChildEnv+"=1",
runtimeTokenDetachedWorkDirEnv+"="+workDir,
runtimeTokenDetachedEndpointEnv+"="+endpoint,
runtimeTokenDetachedEvidenceEnv+"="+evidencePath,
)
assertRuntimeTokenDetachedClean(t, "spawn environment", []byte(strings.Join(childEnv, "\x00")))
pid, err := busctl.Spawn(busctl.SpawnConfig{
ExecPath: os.Args[0],
ClientID: runtimeTokenDetachedClientID,
ExtraArgs: spawnArgs,
Env: childEnv,
})
if err != nil {
failRuntimeTokenDetachedError(t, "spawn detached runtime bus", err)
}
stopped := false
t.Cleanup(func() {
if !stopped {
_ = busctl.Stop(busctl.StopConfig{WorkDir: workDir, Timeout: 2 * time.Second})
if proc, findErr := os.FindProcess(pid); findErr == nil {
_ = proc.Kill()
}
}
})
waitRuntimeTokenDetachedFile(t, evidencePath, "child_start argv_clean=true env_clean=true", 3*time.Second)
var stdoutA, stderrA bytes.Buffer
err = consume.Run(context.Background(), runtimeTokenDetachedConsumeConfig(
workDir, endpoint, "sub-runtime-a", runtimeTokenDetachedCanaryA, 500*time.Millisecond, &stdoutA, &stderrA,
))
if err != nil {
failRuntimeTokenDetachedError(t, "consume token A", err)
}
waitRuntimeTokenDetachedFile(t, evidencePath, "resolved_a=true generation=1", 3*time.Second)
if err := personal.UpsertRunState(workDir, personal.RunState{
SubscribeID: "sub-runtime-b",
EventKey: personal.EventMention,
ClientID: runtimeTokenDetachedClientID,
SourceID: runtimeTokenDetachedSourceID,
IdentityHash: runtimeTokenDetachedIdentityHash,
}); err != nil {
failRuntimeTokenDetachedError(t, "persist non-sensitive run state", err)
}
var stdoutB, stderrB bytes.Buffer
consumeDone := make(chan error, 1)
go func() {
consumeDone <- consume.Run(context.Background(), runtimeTokenDetachedConsumeConfig(
workDir, endpoint, "sub-runtime-b", runtimeTokenDetachedCanaryB, 5*time.Second, &stdoutB, &stderrB,
))
}()
status := waitRuntimeTokenDetachedStatus(t, endpoint, "sub-runtime-b", 3*time.Second)
if status.Bus.PID != pid || status.Bus.IdentityHash != runtimeTokenDetachedIdentityHash {
t.Fatalf("status bus identity = %#v, want pid=%d identity=%s", status.Bus, pid, runtimeTokenDetachedIdentityHash)
}
waitRuntimeTokenDetachedFile(t, evidencePath, "rejected_a=true resolved_b=true reconnect=true generation=2", 3*time.Second)
stopResp, err := busctl.StopConsumers(endpoint, []string{"sub-runtime-b"})
if err != nil {
failRuntimeTokenDetachedError(t, "targeted consumer stop", err)
}
if len(stopResp.Stopped) != 1 || stopResp.Stopped[0] != "sub-runtime-b" {
t.Fatalf("targeted stop response = %#v", stopResp)
}
select {
case err := <-consumeDone:
if err != nil {
failRuntimeTokenDetachedError(t, "consume token B after targeted stop", err)
}
case <-time.After(3 * time.Second):
t.Fatal("token B consumer did not exit after targeted stop")
}
status = waitRuntimeTokenDetachedStatus(t, endpoint, "", 3*time.Second)
if len(status.Consumers) != 0 {
t.Fatalf("status consumers after stop = %#v", status.Consumers)
}
if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir, Timeout: 4 * time.Second}); err != nil {
failRuntimeTokenDetachedError(t, "stop detached bus", err)
}
stopped = true
waitRuntimeTokenDetachedFile(t, evidencePath, "bus_exit clean=true", 3*time.Second)
statusJSON, err := json.Marshal(status)
if err != nil {
t.Fatal(err)
}
stopJSON, err := json.Marshal(stopResp)
if err != nil {
t.Fatal(err)
}
for name, artifact := range map[string][]byte{
"consume A stdout": stdoutA.Bytes(),
"consume A stderr": stderrA.Bytes(),
"consume B stdout": stdoutB.Bytes(),
"consume B stderr": stderrB.Bytes(),
"status response": statusJSON,
"stop response": stopJSON,
} {
assertRuntimeTokenDetachedClean(t, name, artifact)
}
assertRuntimeTokenDetachedTreeClean(t, root)
for _, required := range []string{
filepath.Join(workDir, bus.MetaFileName),
filepath.Join(workDir, "bus.log"),
filepath.Join(workDir, personal.StateFileName),
evidencePath,
} {
if info, statErr := os.Stat(required); statErr != nil || !info.Mode().IsRegular() {
t.Fatalf("expected runtime artifact %s: info=%v err=%v", required, info, statErr)
}
}
}
func runtimeTokenDetachedConsumeConfig(workDir, endpoint, subscribeID, token string, duration time.Duration, stdout, stderr *bytes.Buffer) consume.Config {
return consume.Config{
WorkDir: workDir,
IPCEndpoint: endpoint,
ClientID: runtimeTokenDetachedClientID,
RuntimeToken: token,
EventTypes: []string{personal.EventMention},
EventKey: personal.EventMention,
SubscribeID: subscribeID,
ReadySubscribeID: subscribeID,
Duration: duration,
Format: consume.FormatNDJSON,
Stdout: stdout,
Stderr: stderr,
}
}
func waitRuntimeTokenDetachedFile(t *testing.T, path, want string, timeout time.Duration) string {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
data, err := os.ReadFile(path)
if err == nil && strings.Contains(string(data), want) {
return string(data)
}
time.Sleep(10 * time.Millisecond)
}
data, err := os.ReadFile(path)
if runtimeTokenDetachedContainsCanary(string(data)) {
t.Fatalf("runtime credential leaked into child evidence while waiting for %q", want)
}
t.Fatalf("evidence %s missing %q: data=%q err=%v", path, want, data, err)
return ""
}
func waitRuntimeTokenDetachedStatus(t *testing.T, endpoint, subscribeID string, timeout time.Duration) *transport.StatusResp {
t.Helper()
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
status, err := busctl.QueryStatus(endpoint)
if err == nil {
if subscribeID == "" && len(status.Consumers) == 0 {
return status
}
for _, consumer := range status.Consumers {
if consumer.SubscribeID == subscribeID {
return status
}
}
}
lastErr = err
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("status never reached subscribe_id=%q: %v", subscribeID, lastErr)
return nil
}
func assertRuntimeTokenDetachedTreeClean(t *testing.T, root string) {
t.Helper()
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() || !entry.Type().IsRegular() {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
assertRuntimeTokenDetachedClean(t, path, data)
return nil
})
if err != nil {
t.Fatalf("scan runtime artifacts: %v", err)
}
}
func assertRuntimeTokenDetachedClean(t *testing.T, name string, artifact []byte) {
t.Helper()
if runtimeTokenDetachedContainsCanary(string(artifact)) {
t.Fatalf("runtime credential leaked into %s", name)
}
}
func failRuntimeTokenDetachedError(t *testing.T, step string, err error) {
t.Helper()
if err != nil && runtimeTokenDetachedContainsCanary(err.Error()) {
t.Fatalf("%s failed and exposed a runtime credential", step)
}
t.Fatalf("%s: %v", step, err)
}
@@ -0,0 +1,335 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestCrossPlatformCoverageRuntimeTokenBusRejectsIncompleteIdentity(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
args []string
want string
}{
{
name: "invalid identity hash",
args: []string{"--source-kind", "personal_stream", "--runtime-token-mode", "--identity-hash", "not-a-hash", "--client-id", "client"},
want: "16-character hexadecimal identity hash",
},
{
name: "missing client id",
args: []string{"--source-kind", "personal_stream", "--runtime-token-mode", "--identity-hash", "0123456789abcdef"},
want: "--client-id is required",
},
} {
t.Run(tc.name, func(t *testing.T) {
cmd := newEventBusCommand()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(tc.args)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Execute() error = %v, want %q", err, tc.want)
}
})
}
}
type eventRuntimeTokenReleaseErrorStore struct {
err error
}
func (*eventRuntimeTokenReleaseErrorStore) Claim([]personal.AttemptSpec, time.Duration) (*personal.AttemptClaim, error) {
return nil, nil
}
func (*eventRuntimeTokenReleaseErrorStore) CompleteSuccess(*personal.AttemptClaim) error {
return nil
}
func (*eventRuntimeTokenReleaseErrorStore) CompleteFailure(*personal.AttemptClaim, []string, personal.AttemptFailure) (personal.AttemptHold, error) {
return personal.AttemptHold{}, nil
}
func (s *eventRuntimeTokenReleaseErrorStore) Release(*personal.AttemptClaim) error {
return s.err
}
func TestCrossPlatformCoverageRuntimeTokenAttemptReleaseGuardEdges(t *testing.T) {
var nilReservation *personalSubscriptionAttemptReservation
if err := nilReservation.releaseRuntimeTokenFailure(); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
t.Fatalf("nil reservation error = %v", err)
}
incomplete := &personalSubscriptionAttemptReservation{}
if err := incomplete.releaseRuntimeTokenFailure(); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) ||
!strings.Contains(err.Error(), "reservation is incomplete") {
t.Fatalf("incomplete reservation error = %v", err)
}
wantErr := errors.New("release failed")
reservation := &personalSubscriptionAttemptReservation{
store: &eventRuntimeTokenReleaseErrorStore{err: wantErr},
claim: &personal.AttemptClaim{AttemptID: "attempt"},
}
if err := reservation.releaseRuntimeTokenFailure(); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) ||
!errors.Is(err, wantErr) {
t.Fatalf("release failure error = %v", err)
}
}
func TestCrossPlatformCoverageRuntimeTokenConsumeRejectionAndOversizeEdges(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldEdition := edition.Get()
oldProfile := authpkg.RuntimeProfile()
oldLoadProfiles := personalLoadProfiles
oldValidate := personalValidateConsumeConfig
oldConflict := personalValidateNoOutputConflict
oldAttemptStore := personalNewSubscriptionAttemptStore
oldEnsure := personalEnsureSubscription
t.Cleanup(func() {
edition.Override(oldEdition)
authpkg.SetRuntimeProfile(oldProfile)
personalLoadProfiles = oldLoadProfiles
personalValidateConsumeConfig = oldValidate
personalValidateNoOutputConflict = oldConflict
personalNewSubscriptionAttemptStore = oldAttemptStore
personalEnsureSubscription = oldEnsure
})
edition.Override(&edition.Hooks{})
authpkg.SetRuntimeProfile("")
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
oversized := strings.Repeat("x", runtimecred.DefaultMaxTokenBytes+1)
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
EventKey: personal.EventMention,
ExplicitToken: oversized,
ClientIDOverride: "runtime-client",
Common: commonConsumeOptions{Foreground: true},
})
if !errors.Is(err, runtimecred.ErrTokenTooLarge) {
t.Fatalf("oversized foreground token error = %v", err)
}
rejection := &personal.APIError{
Code: "RUNTIME_TOKEN_REJECTED",
HTTPStatus: http.StatusUnauthorized,
}
if personalRuntimeTokenControlRejection(errors.New("ordinary failure")) {
t.Fatal("ordinary error classified as runtime-token rejection")
}
singleStore := &personalRecordingAttemptStore{}
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore { return singleStore }
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
return nil, "", "", rejection
}
err = runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
EventKey: personal.EventMention,
ExplicitToken: "runtime-token-single",
ClientIDOverride: "runtime-client",
ControlBaseURL: "https://control.example.test",
})
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) || singleStore.releaseCalls != 1 || singleStore.failureCalls != 0 {
t.Fatalf("single rejection = %v, release=%d failure=%d", err, singleStore.releaseCalls, singleStore.failureCalls)
}
manyStore := &personalRecordingAttemptStore{}
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore { return manyStore }
err = runPersonalEventConsumeMany(newPersonalCoverageCommand(), personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
ExplicitToken: "runtime-token-many",
ClientIDOverride: "runtime-client",
ControlBaseURL: "https://control.example.test",
})
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) || manyStore.releaseCalls != 1 || manyStore.failureCalls != 0 {
t.Fatalf("multi rejection = %v, release=%d failure=%d", err, manyStore.releaseCalls, manyStore.failureCalls)
}
}
func TestCrossPlatformCoverageRuntimeTokenIdentityFallbackEdges(t *testing.T) {
configDir := t.TempDir()
oldEdition := edition.Get()
oldProfile := authpkg.RuntimeProfile()
oldResolveIdentity := personalResolveEventIdentity
oldResolveAuxiliary := personalResolveAuxiliaryAccessToken
oldLoadTokenData := personalLoadTokenData
oldLoadProfiles := personalLoadProfiles
oldRuntimeClientID := personalRuntimeEventClientID
oldClientID := personalClientID
oldResolveCredentials := personalResolveAppCredentialsStrict
t.Cleanup(func() {
edition.Override(oldEdition)
authpkg.SetRuntimeProfile(oldProfile)
personalResolveEventIdentity = oldResolveIdentity
personalResolveAuxiliaryAccessToken = oldResolveAuxiliary
personalLoadTokenData = oldLoadTokenData
personalLoadProfiles = oldLoadProfiles
personalRuntimeEventClientID = oldRuntimeClientID
personalClientID = oldClientID
personalResolveAppCredentialsStrict = oldResolveCredentials
})
legacy := personal.Identity{ClientID: "legacy-client", SourceID: "legacy-source"}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return legacy, nil }
identity, err := resolvePersonalEventIdentityForToken(context.Background(), configDir, "", " ")
if err != nil || identity.ClientID != legacy.ClientID {
t.Fatalf("wrapper empty-token fallback = %#v, %v", identity, err)
}
personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) {
return "legacy-access", nil
}
personalLoadTokenData = func(string) (*authpkg.TokenData, error) {
return &authpkg.TokenData{
CorpID: "legacy-corp", UserID: "legacy-user", ClientID: "direct-client",
}, nil
}
identity, err = resolvePersonalEventIdentityWithToken(context.Background(), configDir, "", " ")
if err != nil || identity.ClientID != "direct-client" {
t.Fatalf("direct empty-token fallback = %#v, %v", identity, err)
}
edition.Override(&edition.Hooks{})
personalRuntimeEventClientID = func() string { return "" }
personalClientID = func() string { return "" }
wantMetadataErr := errors.New("profiles unreadable")
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, wantMetadataErr }
authpkg.SetRuntimeProfile("corp:user")
if _, err := resolvePersonalEventIdentityWithToken(context.Background(), configDir, "", "token", "runtime-client"); !errors.Is(err, wantMetadataErr) {
t.Fatalf("explicit profile metadata error = %v", err)
}
authpkg.SetRuntimeProfile("")
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
return "app-client", "", "", "", nil
}
identity, err = resolvePersonalEventIdentityWithToken(context.Background(), configDir, "", "runtime-token")
if err != nil || identity.ClientID != "app-client" || !strings.HasPrefix(identity.LocalSubject, "access:") {
t.Fatalf("app-credential fallback identity = %#v, %v", identity, err)
}
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
return "", "", "", "", errors.New("missing app credentials")
}
if _, err := resolvePersonalEventIdentityWithToken(context.Background(), configDir, "", "runtime-token"); err == nil || !strings.Contains(err.Error(), "cannot resolve OAuth client_id") {
t.Fatalf("missing client ID error = %v", err)
}
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{
CurrentProfile: "stale",
Profiles: []authpkg.Profile{{Name: "other", CorpID: "corp", UserID: "user"}},
}, nil
}
profile, err := personalEventProfileMetadata(configDir)
if err != nil || profile != nil {
t.Fatalf("stale implicit current profile = %#v, %v", profile, err)
}
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{{Name: "other"}}}, nil
}
profile, err = personalEventProfileMetadata(configDir)
if err != nil || profile != nil {
t.Fatalf("empty implicit selector = %#v, %v", profile, err)
}
authpkg.SetRuntimeProfile("corp-a:user-a,corp-b:user-b")
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
if _, err := personalEventProfileMetadata(configDir); err == nil || !strings.Contains(err.Error(), "exactly one --profile") {
t.Fatalf("multi-profile metadata error = %v", err)
}
}
type eventRuntimeTokenReadErrorBody struct{}
func (eventRuntimeTokenReadErrorBody) Read([]byte) (int, error) {
return 0, errors.New("body read failed")
}
func (eventRuntimeTokenReadErrorBody) Close() error { return nil }
func TestCrossPlatformCoverageRuntimeTokenControlTransportErrorEdges(t *testing.T) {
const token = "runtime-control-edge-canary"
unsupported, err := http.NewRequest(http.MethodGet, "unsupported://control.example.test/path", nil)
if err != nil {
t.Fatal(err)
}
if _, err := (runtimeTokenControlTransport{}).RoundTrip(unsupported); err == nil {
t.Fatal("nil base unexpectedly accepted an unsupported protocol")
}
wantTransportErr := errors.New("ordinary transport failure")
ordinary := runtimeTokenControlTransport{base: eventRuntimeRoundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, wantTransportErr
})}
if _, err := ordinary.RoundTrip(unsupported); !errors.Is(err, wantTransportErr) {
t.Fatalf("ordinary transport error = %v", err)
}
leaking := runtimeTokenControlTransport{
token: token,
base: eventRuntimeRoundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("reflected " + token)
}),
}
if _, err := leaking.RoundTrip(unsupported); err == nil || strings.Contains(err.Error(), token) ||
err.Error() != "personal event: runtime-token control request failed" {
t.Fatalf("redacted transport error = %v", err)
}
nilResponse := runtimeTokenControlTransport{base: eventRuntimeRoundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, nil
})}
if resp, err := nilResponse.RoundTrip(unsupported); resp != nil || err != nil {
t.Fatalf("nil response = %#v, %v", resp, err)
}
readFailure := runtimeTokenControlTransport{base: eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusInternalServerError,
Header: make(http.Header),
Body: eventRuntimeTokenReadErrorBody{},
Request: req,
}, nil
})}
if _, err := readFailure.RoundTrip(unsupported); err == nil || !strings.Contains(err.Error(), "read runtime-token control response") {
t.Fatalf("body read error = %v", err)
}
nilHeader := runtimeTokenControlTransport{base: eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Request: req}, nil
})}
resp, err := nilHeader.RoundTrip(unsupported)
if err != nil || resp == nil || resp.Header == nil || resp.Header.Get("Content-Type") != "application/json" {
t.Fatalf("nil-header response = %#v, %v", resp, err)
}
if got := redactRuntimeTokenResponseBody(nil, token); len(got) != 0 {
t.Fatalf("empty response redaction = %q", got)
}
value, changed := redactRuntimeTokenJSONValue([]any{"plain", "prefix-" + token}, token)
items, ok := value.([]any)
if !ok || !changed || len(items) != 2 || strings.Contains(items[1].(string), token) {
t.Fatalf("array redaction = %#v changed=%t", value, changed)
}
}
@@ -0,0 +1,917 @@
// 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.
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageEventCommandsWireTrimmedRootRuntimeToken(t *testing.T) {
oldConsume := eventRunPersonalConsume
oldStatus := eventRunPersonalStatus
oldStop := eventRunPersonalStop
t.Cleanup(func() {
eventRunPersonalConsume = oldConsume
eventRunPersonalStatus = oldStatus
eventRunPersonalStop = oldStop
})
flags := &GlobalFlags{Token: " runtime-canary ", ClientID: " root-client "}
assertIdentity := func(token, clientID string) {
t.Helper()
if token != "runtime-canary" || clientID != "root-client" {
t.Fatalf("runtime identity = token %q client %q", token, clientID)
}
}
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
assertIdentity(opts.ExplicitToken, opts.ClientIDOverride)
return nil
}
consumeCmd := newEventConsumeCommand(flags)
if err := consumeCmd.RunE(consumeCmd, []string{personal.EventMention}); err != nil {
t.Fatalf("consume RunE() error = %v", err)
}
eventRunPersonalStatus = func(_ *cobra.Command, opts personalStatusOptions) error {
assertIdentity(opts.ExplicitToken, opts.ClientIDOverride)
return nil
}
statusCmd := newEventStatusCommandWithFlags(flags)
if err := statusCmd.RunE(statusCmd, nil); err != nil {
t.Fatalf("status RunE() error = %v", err)
}
eventRunPersonalStop = func(_ *cobra.Command, opts personalStopOptions) error {
assertIdentity(opts.ExplicitToken, opts.ClientIDOverride)
return nil
}
stopCmd := newEventStopCommandWithFlags(flags)
stopRoot := &cobra.Command{Use: "dws"}
stopRoot.PersistentFlags().Bool("yes", true, "")
stopRoot.AddCommand(stopCmd)
if err := stopCmd.RunE(stopCmd, []string{"sub-runtime"}); err != nil {
t.Fatalf("stop RunE() error = %v", err)
}
listenCmd := newEventListenIMCommand(flags)
if err := listenCmd.RunE(listenCmd, nil); err != nil {
t.Fatalf("listen-im RunE() error = %v", err)
}
}
func TestCrossPlatformCoverageEventConsumeParsesRootRuntimeTokenBeforeAndAfterSubcommand(t *testing.T) {
oldConsume := eventRunPersonalConsume
t.Cleanup(func() { eventRunPersonalConsume = oldConsume })
for _, tc := range []struct {
name string
args []string
}{
{name: "before", args: []string{"--token", "runtime-before", "event", "consume", personal.EventMention}},
{name: "after", args: []string{"event", "consume", personal.EventMention, "--token", "runtime-after"}},
} {
t.Run(tc.name, func(t *testing.T) {
flags := &GlobalFlags{}
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
bindPersistentFlags(root, flags)
root.AddCommand(newEventCommand(flags))
var got string
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
got = opts.ExplicitToken
return nil
}
root.SetArgs(tc.args)
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
want := "runtime-" + tc.name
if got != want {
t.Fatalf("ExplicitToken = %q, want %q", got, want)
}
})
}
}
func TestCrossPlatformCoverageResolvePersonalEventIdentityWithTokenUsesMetadataOnly(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
oldLoadToken := personalLoadTokenData
oldAux := personalResolveAuxiliaryAccessToken
oldClientID := personalClientID
oldCredentials := personalResolveAppCredentialsStrict
previousProfile := authpkg.RuntimeProfile()
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
personalLoadTokenData = oldLoadToken
personalResolveAuxiliaryAccessToken = oldAux
personalClientID = oldClientID
personalResolveAppCredentialsStrict = oldCredentials
authpkg.SetRuntimeProfile(previousProfile)
})
personalLoadTokenData = func(string) (*authpkg.TokenData, error) {
t.Fatal("explicit token identity read sensitive TokenData")
return nil, nil
}
personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) {
t.Fatal("explicit token identity resolved local OAuth")
return "", nil
}
personalClientID = func() string {
t.Fatal("explicit root client ID was not preferred")
return ""
}
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
t.Fatal("explicit root client ID unexpectedly fell back to app credentials")
return "", "", "", "", nil
}
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{
Version: 2,
Profiles: []authpkg.Profile{{
Name: "Runtime profile",
CorpID: "profile-corp",
CorpName: "Runtime Org",
UserID: "profile-user",
UserName: "Runtime User",
ClientID: "profile-client",
}},
}, nil
}
authpkg.SetRuntimeProfile("Runtime Org:Runtime User")
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "", false },
}
}})
identity, err := resolvePersonalEventIdentityWithToken(
context.Background(), "unused", "runtime-source", " runtime-canary ", "root-client",
)
if err != nil {
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
}
if identity.CorpID != "runtime-corp" || identity.UserID != "profile-user" || identity.ClientID != "root-client" {
t.Fatalf("identity metadata = %#v", identity)
}
if identity.AccessToken != "" {
t.Fatalf("identity retained raw runtime token: %q", identity.AccessToken)
}
if identity.LocalSubject != "" {
t.Fatalf("complete identity LocalSubject = %q, want empty", identity.LocalSubject)
}
}
func TestCrossPlatformCoverageResolvePersonalEventIdentityWithCompleteRuntimeMetadataSkipsProfiles(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
oldRuntimeClientID := personalRuntimeEventClientID
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
personalRuntimeEventClientID = oldRuntimeClientID
})
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
t.Fatal("complete host metadata unexpectedly read profiles.json")
return nil, nil
}
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
personalRuntimeEventClientID = func() string { return "edition-client" }
identity, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary", "root-client")
if err != nil {
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
}
if identity.CorpID != "runtime-corp" || identity.UserID != "runtime-user" || identity.ClientID != "root-client" {
t.Fatalf("identity = %#v", identity)
}
}
func TestCrossPlatformCoverageRuntimeEventClientIDPrefersEditionBeforeEnvironment(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
t.Setenv("DWS_CLIENT_ID", "environment-client")
edition.Override(&edition.Hooks{AuthClientID: "edition-client"})
if got := runtimePersonalEventClientID(); got != "edition-client" {
t.Fatalf("runtime client ID = %q, want edition hook", got)
}
edition.Override(&edition.Hooks{})
if got := runtimePersonalEventClientID(); got != "environment-client" {
t.Fatalf("runtime client ID = %q, want environment fallback", got)
}
}
func TestCrossPlatformCoverageCompleteRuntimeIdentityUsesEditionClientBeforeProfiles(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
oldRuntimeClientID := personalRuntimeEventClientID
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
personalRuntimeEventClientID = oldRuntimeClientID
})
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
t.Fatal("complete host metadata unexpectedly read profiles.json")
return nil, errors.New("unreachable")
}
personalRuntimeEventClientID = func() string { return "edition-client" }
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
identity, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary")
if err != nil {
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
}
if identity.CorpID != "runtime-corp" || identity.UserID != "runtime-user" || identity.ClientID != "edition-client" {
t.Fatalf("identity = %#v", identity)
}
}
func TestCrossPlatformCoverageSelectedProfileClientPrecedesPersistedGlobalClient(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
oldRuntimeClientID := personalRuntimeEventClientID
oldClientID := personalClientID
previousProfile := authpkg.RuntimeProfile()
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
personalRuntimeEventClientID = oldRuntimeClientID
personalClientID = oldClientID
authpkg.SetRuntimeProfile(previousProfile)
})
edition.Override(&edition.Hooks{})
personalRuntimeEventClientID = func() string { return "" }
personalClientID = func() string { return "stale-global-client" }
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{
Version: 3,
CurrentProfile: "corp:user",
Profiles: []authpkg.Profile{{
Name: "Selected", CorpID: "corp", UserID: "user", ClientID: "profile-client",
}},
}, nil
}
authpkg.SetRuntimeProfile("corp:user")
identity, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary")
if err != nil {
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
}
if identity.ClientID != "profile-client" {
t.Fatalf("ClientID = %q, want selected profile client", identity.ClientID)
}
}
func TestCrossPlatformCoverageMalformedPersistedProfilesDoNotBlockRuntimeDefaultsAndGlobalClient(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
oldRuntimeClientID := personalRuntimeEventClientID
oldClientID := personalClientID
previousProfile := authpkg.RuntimeProfile()
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
personalRuntimeEventClientID = oldRuntimeClientID
personalClientID = oldClientID
authpkg.SetRuntimeProfile(previousProfile)
})
authpkg.SetRuntimeProfile("")
personalRuntimeEventClientID = func() string { return "" }
personalClientID = func() string { return "global-client" }
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return nil, errors.New("malformed persisted profiles")
}
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
identity, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary")
if err != nil {
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
}
if identity.CorpID != "runtime-corp" || identity.UserID != "runtime-user" || identity.ClientID != "global-client" {
t.Fatalf("identity = %#v", identity)
}
}
func TestCrossPlatformCoverageResolvePersonalEventIdentityWithTokenRejectsMultipleProfilesBeforeMetadata(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
previousProfile := authpkg.RuntimeProfile()
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
authpkg.SetRuntimeProfile(previousProfile)
})
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
t.Fatal("multiple runtime profiles unexpectedly reached metadata loading")
return nil, nil
}
authpkg.SetRuntimeProfile("corp-a:user-a,corp-b:user-b")
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
_, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary", "root-client")
if err == nil || !strings.Contains(err.Error(), "exactly one --profile") {
t.Fatalf("multiple-profile error = %v", err)
}
}
func TestCrossPlatformCoverageExplicitProfileRequiresMetadataRegistry(t *testing.T) {
oldLoadProfiles := personalLoadProfiles
defer func() { personalLoadProfiles = oldLoadProfiles }()
oldProfile := authpkg.RuntimeProfile()
authpkg.SetRuntimeProfile("missing-profile")
defer authpkg.SetRuntimeProfile(oldProfile)
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{}, nil
}
_, err := personalEventProfileMetadata(t.TempDir())
if err == nil || !strings.Contains(err.Error(), `profile "missing-profile" not found`) {
t.Fatalf("personalEventProfileMetadata() error = %v", err)
}
}
func TestCrossPlatformCoverageCompleteRuntimeIdentityStillValidatesExplicitProfile(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
oldRuntimeClientID := personalRuntimeEventClientID
oldProfile := authpkg.RuntimeProfile()
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
personalRuntimeEventClientID = oldRuntimeClientID
authpkg.SetRuntimeProfile(oldProfile)
})
authpkg.SetRuntimeProfile("missing-profile")
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{}, nil
}
personalRuntimeEventClientID = func() string { return "runtime-client" }
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
_, err := resolvePersonalEventIdentityWithToken(context.Background(), t.TempDir(), "source", "canary")
if err == nil || !strings.Contains(err.Error(), `profile "missing-profile" not found`) {
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
}
}
func TestCrossPlatformCoveragePersonalProfileMetadataOrganizationCurrentBeatsUnresolved(t *testing.T) {
cfg := &authpkg.ProfilesConfig{
Version: 3,
Profiles: []authpkg.Profile{
{Name: "Historical", CorpID: "corp-1"},
{Name: "Exact", CorpID: "corp-1", UserID: "user-1"},
},
OrgCurrentProfiles: map[string]string{"corp-1": "corp-1:user-1"},
}
profile, err := selectPersonalEventProfileMetadata(cfg, "corp-1", make(map[string]struct{}))
if err != nil {
t.Fatalf("selectPersonalEventProfileMetadata() error = %v", err)
}
if profile == nil || profile.UserID != "user-1" {
t.Fatalf("selected profile = %#v, want organization current account", profile)
}
}
func TestCrossPlatformCoverageExplicitTokenControlClientRedactsReflected401(t *testing.T) {
const token = "runtime-control-canary"
oldLogger := slog.Default()
var logs bytes.Buffer
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() { slog.SetDefault(oldLogger) })
client := newPersonalEventControlClient("unused", "https://control.invalid", personal.Identity{
ClientID: "client", SourceID: "source",
}, token)
wrapped, ok := client.HTTPClient.Transport.(runtimeTokenControlTransport)
if !ok {
t.Fatalf("control transport = %T, want runtimeTokenControlTransport", client.HTTPClient.Transport)
}
var authorization string
wrapped.base = eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
authorization = req.Header.Get("Authorization")
body := `{"code":"UNAUTHORIZED","message":"rejected ` + token + `"}`
header := make(http.Header)
header.Set("X-Request-Id", "request-"+token)
header.Set("X-Trace-Id", "trace-"+token)
return &http.Response{
StatusCode: http.StatusUnauthorized,
Header: header,
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
})
client.HTTPClient.Transport = wrapped
_, err := client.ListSubscriptions(context.Background(), personal.ListOptions{})
if err == nil {
t.Fatal("ListSubscriptions() unexpectedly succeeded")
}
if authorization != "Bearer "+token {
t.Fatalf("Authorization = %q", authorization)
}
if strings.Contains(err.Error(), token) || strings.Contains(logs.String(), token) {
t.Fatalf("runtime token leaked: error=%q logs=%q", err, logs.String())
}
if !strings.Contains(err.Error(), "RUNTIME_TOKEN_REJECTED") {
t.Fatalf("error = %q, want fixed runtime token rejection", err)
}
}
func TestCrossPlatformCoverageRuntimeTokenRedirectGuardDoesNotForwardCustomHeader(t *testing.T) {
const token = "runtime-redirect-canary"
var controlTargetHits, ticketTargetHits atomic.Int32
controlTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
controlTargetHits.Add(1)
if r.Header.Get("x-user-access-token") == token {
t.Error("control redirect forwarded runtime token")
}
w.WriteHeader(http.StatusNoContent)
}))
defer controlTarget.Close()
controlOrigin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("x-user-access-token") != token {
t.Error("control origin did not receive runtime token")
}
http.Redirect(w, r, controlTarget.URL, http.StatusFound)
}))
defer controlOrigin.Close()
client := newPersonalEventControlClient("unused", controlOrigin.URL, personal.Identity{
ClientID: "client", SourceID: "source",
}, token)
_, controlErr := client.ListSubscriptions(context.Background(), personal.ListOptions{})
if controlErr == nil {
t.Fatal("cross-host control redirect unexpectedly succeeded")
}
if controlTargetHits.Load() != 0 || strings.Contains(controlErr.Error(), token) {
t.Fatalf("control redirect hits=%d error=%q", controlTargetHits.Load(), controlErr)
}
ticketTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ticketTargetHits.Add(1)
if r.Header.Get("x-user-access-token") == token {
t.Error("ticket redirect forwarded runtime token")
}
w.WriteHeader(http.StatusNoContent)
}))
defer ticketTarget.Close()
ticketOrigin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("x-user-access-token") != token {
t.Error("ticket origin did not receive runtime token")
}
http.Redirect(w, r, ticketTarget.URL, http.StatusFound)
}))
defer ticketOrigin.Close()
broker := runtimecred.New(runtimecred.Config{RequireSeed: true})
if _, err := broker.Update(0, token); err != nil {
t.Fatal(err)
}
src, err := newPersonalStreamSource(context.Background(), personalStreamSourceOptions{
ConfigDir: "unused",
Identity: personal.Identity{ClientID: "client", SourceID: "source"},
TicketURL: ticketOrigin.URL,
CredentialBroker: broker,
RuntimeTokenMode: true,
})
if err != nil {
t.Fatal(err)
}
err = src.Start(context.Background(), func(*dwsevent.RawEvent) {})
if err == nil {
t.Fatal("cross-host ticket redirect unexpectedly succeeded")
}
if ticketTargetHits.Load() != 0 || strings.Contains(err.Error(), token) {
t.Fatalf("ticket redirect hits=%d error=%q", ticketTargetHits.Load(), err)
}
}
func TestCrossPlatformCoverageRuntimeTokenRedirectPolicyBranches(t *testing.T) {
origin, _ := http.NewRequest(http.MethodGet, "https://control.example/start", nil)
sameHost, _ := http.NewRequest(http.MethodGet, "https://control.example/next", nil)
if err := runtimeTokenRedirectPolicy(sameHost, []*http.Request{origin}); err != nil {
t.Fatalf("same-host HTTPS redirect rejected: %v", err)
}
for name, request := range map[string]*http.Request{
"cross-host": func() *http.Request {
r, _ := http.NewRequest(http.MethodGet, "https://other.example/next", nil)
return r
}(),
"downgrade": func() *http.Request {
r, _ := http.NewRequest(http.MethodGet, "http://control.example/next", nil)
return r
}(),
} {
if err := runtimeTokenRedirectPolicy(request, []*http.Request{origin}); !errors.Is(err, http.ErrUseLastResponse) {
t.Fatalf("%s redirect policy error = %v", name, err)
}
}
if err := runtimeTokenRedirectPolicy(nil, nil); !errors.Is(err, http.ErrUseLastResponse) {
t.Fatalf("empty redirect chain error = %v", err)
}
}
func TestCrossPlatformCoverageExplicitTokenControlClientRedactsEveryErrorEnvelope(t *testing.T) {
const token = "runtime-control-all-status-canary"
tests := []struct {
name string
status int
body string
}{
{name: "bad-request", status: http.StatusBadRequest, body: `{"code":"BAD_REQUEST","message":"` + token + `"}`},
{name: "server-error", status: http.StatusInternalServerError, body: `{"code":"INTERNAL","message":"` + token + `"}`},
{name: "success-false", status: http.StatusOK, body: `{"success":false,"errorCode":"DENIED","errorMsg":"` + token + `"}`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
oldLogger := slog.Default()
var logs bytes.Buffer
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() { slog.SetDefault(oldLogger) })
client := newPersonalEventControlClient("unused", "https://control.invalid", personal.Identity{
ClientID: "client", SourceID: "source",
}, token)
wrapped := client.HTTPClient.Transport.(runtimeTokenControlTransport)
wrapped.base = eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
header := make(http.Header)
header.Set("X-Request-Id", "request-"+token)
header.Set("X-Trace-Id", "trace-"+token)
return &http.Response{
StatusCode: tc.status,
Header: header,
Body: io.NopCloser(strings.NewReader(tc.body)),
Request: req,
}, nil
})
client.HTTPClient.Transport = wrapped
_, err := client.ListSubscriptions(context.Background(), personal.ListOptions{})
if err == nil {
t.Fatal("ListSubscriptions() unexpectedly succeeded")
}
if strings.Contains(err.Error(), token) || strings.Contains(logs.String(), token) {
t.Fatalf("runtime token leaked: error=%q logs=%q", err, logs.String())
}
})
}
}
func TestCrossPlatformCoverageExplicitTokenControlTransportPreservesSuccessfulResponse(t *testing.T) {
client := newPersonalEventControlClient("unused", "https://control.invalid", personal.Identity{
ClientID: "client", SourceID: "source",
}, "runtime-success-canary")
wrapped := client.HTTPClient.Transport.(runtimeTokenControlTransport)
wrapped.base = eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"success":true,"result":{"items":[],"total":0}}`)),
Request: req,
}, nil
})
client.HTTPClient.Transport = wrapped
if _, err := client.ListSubscriptions(context.Background(), personal.ListOptions{}); err != nil {
t.Fatalf("ListSubscriptions() successful response error = %v", err)
}
}
func TestCrossPlatformCoverageExplicitTokenControlClientRedactsJSONEscapedToken(t *testing.T) {
const token = "runtime<escaped>&canary"
body, err := json.Marshal(map[string]any{"code": "BAD_REQUEST", "message": "rejected " + token})
if err != nil {
t.Fatal(err)
}
if bytes.Contains(body, []byte(token)) {
t.Fatalf("fixture was not JSON-escaped: %s", body)
}
oldLogger := slog.Default()
var logs bytes.Buffer
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() { slog.SetDefault(oldLogger) })
client := newPersonalEventControlClient("unused", "https://control.invalid", personal.Identity{
ClientID: "client", SourceID: "source",
}, token)
wrapped := client.HTTPClient.Transport.(runtimeTokenControlTransport)
wrapped.base = eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadRequest,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewReader(body)),
Request: req,
}, nil
})
client.HTTPClient.Transport = wrapped
_, err = client.ListSubscriptions(context.Background(), personal.ListOptions{})
if err == nil {
t.Fatal("ListSubscriptions() unexpectedly succeeded")
}
if strings.Contains(err.Error(), token) || strings.Contains(logs.String(), token) {
t.Fatalf("escaped runtime token leaked: error=%q logs=%q", err, logs.String())
}
}
func TestCrossPlatformCoverageRuntimeTokenBusModeSkipsLocalOAuthIdentity(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldResolve := eventResolvePersonal
oldSource := eventNewPersonalSource
oldRun := eventBusRun
t.Cleanup(func() {
eventResolvePersonal = oldResolve
eventNewPersonalSource = oldSource
eventBusRun = oldRun
})
resolvedLocal := false
eventResolvePersonal = func(context.Context, string, string) (personal.Identity, error) {
resolvedLocal = true
return personal.Identity{}, nil
}
var sourceOpts personalStreamSourceOptions
eventNewPersonalSource = func(_ context.Context, opts personalStreamSourceOptions) (*source.PersonalSource, error) {
sourceOpts = opts
return nil, nil
}
var busCfg bus.Config
eventBusRun = func(_ context.Context, cfg bus.Config) error {
busCfg = cfg
return nil
}
cmd := newEventBusCommand()
cmd.SetArgs([]string{
"--source-kind", "personal_stream",
"--runtime-token-mode",
"--identity-hash", "0123456789abcdef",
"--client-id", "runtime-client",
"--stream-source-id", "runtime-source",
"--idle-timeout", "0",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("event _bus runtime mode error = %v", err)
}
if resolvedLocal {
t.Fatal("runtime token bus resolved local OAuth identity")
}
if sourceOpts.CredentialBroker == nil || busCfg.CredentialBroker != sourceOpts.CredentialBroker {
t.Fatal("personal source and bus did not share one credential broker")
}
if busCfg.IdentityHash != "0123456789abcdef" || busCfg.ClientID != "runtime-client" || busCfg.SourceID != "runtime-source" {
t.Fatalf("bus identity = %#v", busCfg)
}
generation, err := sourceOpts.CredentialBroker.Update(0, "detached-activation-canary")
if err != nil {
t.Fatalf("seed detached broker: %v", err)
}
waitCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if _, err := sourceOpts.CredentialBroker.Resolve(waitCtx); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("detached broker resolved before consumer activation: %v", err)
}
if _, err := sourceOpts.CredentialBroker.Activate(generation); err != nil {
t.Fatalf("activate detached broker: %v", err)
}
if resolved, err := sourceOpts.CredentialBroker.Resolve(context.Background()); err != nil || resolved == "" {
t.Fatalf("detached broker did not resolve after activation: %v", err)
}
}
func TestCrossPlatformCoverageForegroundRuntimeBrokerDoesNotRequireActivation(t *testing.T) {
broker := newPersonalCredentialBroker(t.TempDir(), true, false)
if _, err := broker.Update(0, "foreground-activation-canary"); err != nil {
t.Fatalf("seed foreground broker: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if resolved, err := broker.Resolve(ctx); err != nil || resolved == "" {
t.Fatalf("foreground broker unexpectedly waited for activation: %v", err)
}
}
func TestCrossPlatformCoveragePersonalRuntimeBusSpawnArgsContainNoSecretOrProfile(t *testing.T) {
const token = "runtime-spawn-canary"
args := personalBusSpawnArgsForToken(personal.Identity{
ClientID: "client", SourceID: "source", CorpID: "corp", UserID: "user",
}, "identity-hash", "normal", "https://ticket.invalid", "corp:user", token)
joined := strings.Join(args, " ")
for _, forbidden := range []string{token, "--profile", "corp:user"} {
if strings.Contains(joined, forbidden) {
t.Fatalf("spawn args leaked %q: %q", forbidden, joined)
}
}
for _, required := range []string{"--runtime-token-mode", "--identity-hash", "identity-hash", "--stream-source-id", "source"} {
if !strings.Contains(joined, required) {
t.Fatalf("spawn args %q missing %q", joined, required)
}
}
}
func TestCrossPlatformCoverageUnsupportedOldBusDoesNotDeleteReusedSubscription(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldEdition := edition.Get()
oldEnsure := personalEnsureSubscription
oldUpsert := personalUpsertRunState
oldDelete := personalDeleteSubscription
oldRemove := personalRemoveRunStates
oldConsume := personalConsumeRun
oldValidate := personalValidateConsumeConfig
oldConflict := personalValidateNoOutputConflict
t.Cleanup(func() {
edition.Override(oldEdition)
personalEnsureSubscription = oldEnsure
personalUpsertRunState = oldUpsert
personalDeleteSubscription = oldDelete
personalRemoveRunStates = oldRemove
personalConsumeRun = oldConsume
personalValidateConsumeConfig = oldValidate
personalValidateNoOutputConflict = oldConflict
})
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
return &personal.Subscription{SubscribeID: "sub-existing"}, personal.EventMention, "at", nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
deleteCalls := 0
personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
deleteCalls++
return nil
}
var removed []string
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
personalConsumeRun = func(_ context.Context, cfg consume.Config) error {
if strings.TrimSpace(cfg.RuntimeToken) == "" {
t.Fatal("runtime token was not wired to consume")
}
return &consume.RuntimeTokenUnsupportedError{BusPID: 72}
}
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
SubscribeID: "sub-existing",
ExplicitToken: "old-bus-cleanup-canary",
ClientIDOverride: "runtime-client",
})
if !errors.Is(err, consume.ErrRuntimeTokenUnsupported) {
t.Fatalf("consume error = %v", err)
}
if deleteCalls != 0 {
t.Fatalf("reused remote subscription was deleted %d time(s)", deleteCalls)
}
if len(removed) != 0 {
t.Fatalf("reused local run-state was removed: %#v", removed)
}
}
func TestCrossPlatformCoverageRuntimeTokenReusedDryRunUsesExplicitControlCredential(t *testing.T) {
const token = "runtime-dry-run-control-canary"
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldEdition := edition.Get()
oldEnsure := personalEnsureSubscription
oldUpsert := personalUpsertRunState
oldConsume := personalConsumeRun
oldBusRun := personalBusRun
t.Cleanup(func() {
edition.Override(oldEdition)
personalEnsureSubscription = oldEnsure
personalUpsertRunState = oldUpsert
personalConsumeRun = oldConsume
personalBusRun = oldBusRun
})
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
personalEnsureSubscription = func(ctx context.Context, client *personal.Client, _ personal.Identity, _ personalConsumeOptions) (*personal.Subscription, string, string, error) {
if _, ok := client.HTTPClient.Transport.(runtimeTokenControlTransport); !ok {
t.Fatalf("control transport = %T, want runtimeTokenControlTransport", client.HTTPClient.Transport)
}
got, err := client.AccessTokenProvider(ctx)
if err != nil || got != token {
t.Fatalf("control token = %q, %v", got, err)
}
return &personal.Subscription{SubscribeID: "sub-existing"}, personal.EventMention, "at", nil
}
personalUpsertRunState = func(string, personal.RunState) error {
t.Fatal("dry-run unexpectedly persisted run state")
return nil
}
consumeCalls := 0
personalConsumeRun = func(_ context.Context, cfg consume.Config) error {
consumeCalls++
if !cfg.DryRun {
t.Fatal("consume config is not dry-run")
}
if strings.Contains(strings.Join(cfg.SpawnExtraArgs, " "), token) {
t.Fatal("dry-run spawn args leaked runtime token")
}
return nil
}
personalBusRun = func(context.Context, bus.Config) error {
t.Fatal("dry-run unexpectedly started a bus")
return nil
}
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
SubscribeID: "sub-existing",
ExplicitToken: token,
ClientIDOverride: "runtime-client",
Common: commonConsumeOptions{DryRun: true},
})
if err != nil {
t.Fatalf("dry-run consume error = %v", err)
}
if consumeCalls != 1 {
t.Fatalf("dry-run consume calls = %d, want 1", consumeCalls)
}
}
func TestCrossPlatformCoverageRuntimeTokenControlRejectionReleasesSubscriptionClaim(t *testing.T) {
store := &personalRecordingAttemptStore{}
reservation := &personalSubscriptionAttemptReservation{
store: store,
claim: &personal.AttemptClaim{AttemptID: "runtime-token-attempt"},
items: []personalSubscriptionAttemptItem{{eventKey: personal.EventMention, fingerprint: strings.Repeat("a", 64)}},
}
cause := &personal.APIError{
Code: "RUNTIME_TOKEN_REJECTED",
Message: "event runtime token was rejected; retry with a fresh host credential",
HTTPStatus: http.StatusUnauthorized,
}
if !personalRuntimeTokenControlRejection(cause) {
t.Fatal("runtime token control rejection was not classified")
}
err := reservation.releaseRuntimeTokenFailure()
if err == nil || !strings.Contains(err.Error(), "runtime token was rejected") {
t.Fatalf("releaseRuntimeTokenFailure() error = %v", err)
}
if store.releaseCalls != 1 || store.failureCalls != 0 {
t.Fatalf("attempt store release=%d failure=%d, want release only", store.releaseCalls, store.failureCalls)
}
}
type eventRuntimeRoundTripFunc func(*http.Request) (*http.Response, error)
func (f eventRuntimeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
+525
View File
@@ -0,0 +1,525 @@
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func TestFrameworkErrorProjectionPreservesRecoveryMetadata(t *testing.T) {
next := time.Date(2026, 8, 10, 1, 2, 3, 0, time.FixedZone("test", 8*60*60))
retry := int64(4)
started := true
leaf := &helpers.CLIError{Code: "UPSTREAM_CODE", Suggestion: "retry with id", Operation: "create"}
call := &transport.CallError{Stage: transport.CallStage("decode"), HTTPStatus: 503, RPCCode: 91, TraceID: "call-trace", Cause: leaf}
typed := &apperrors.Error{
Category: apperrors.CategoryAPI, Message: "failed", Reason: "upstream_failed", Hint: "use status",
Actions: []string{"dws status"}, Retryable: true, RetryableSet: true, RetryAfterSeconds: &retry,
RPCCode: 92, RPCData: json.RawMessage(`{"task":"x"}`), Operation: "publish", ServerKey: "server",
Origin: "gateway", FailureStage: "response", ExecutionStarted: &started, NextRetryAt: &next,
AvailableFlags: []string{"--id"}, Snapshot: "/tmp/snapshot", Details: map[string]any{"id": "x"},
ServerDiag: apperrors.ServerDiagnostics{TraceID: "typed-trace", ServerErrorCode: "SERVER_CODE", TechnicalDetail: "detail", FriendlyHint: "friendly", ActionURL: "https://example.test"},
Cause: call,
}
info := errorInfoFromExecutionError(typed)
if info.Type != "api" || info.Subtype != "upstream_failed" || info.HTTPStatus != 503 || info.RPCCode != 92 || info.RequestID != "call-trace" || info.TraceID != "typed-trace" {
t.Fatalf("projection=%+v", info)
}
if info.UpstreamCode != "SERVER_CODE" || info.Operation != "publish" || info.NextRetryAt == "" || info.Cause == "" || info.RPCData == nil || info.ExecutionStarted == nil || !*info.ExecutionStarted {
t.Fatalf("recovery metadata=%+v", info)
}
innerOperation := &helpers.CLIError{Operation: "create"}
outerWithoutOperation := &apperrors.Error{
Category: apperrors.CategoryAPI,
Message: "failed",
Cause: innerOperation,
}
preserved := errorInfoFromExecutionError(outerWithoutOperation)
if preserved.Operation != "create" {
t.Fatalf("operation=%q, want inner operation preserved", preserved.Operation)
}
requestCall := &transport.CallError{Stage: transport.CallStage("request"), HTTPStatus: 429, RequestID: "request-id"}
requestInfo := errorInfoFromExecutionError(requestCall)
if requestInfo.RequestID != "request-id" || requestInfo.HTTPStatus != 429 {
t.Fatalf("request projection=%+v", requestInfo)
}
partial := errorInfoFromExecutionError(&apperrors.Error{Category: apperrors.CategoryPartial, Message: "partial"})
if partial.Type != "internal" {
t.Fatalf("partial error type=%s", partial.Type)
}
for code, want := range map[int]string{1: "api", 2: "auth", 3: "validation", 4: "permission", 6: "discovery", 99: "internal"} {
if got := errorTypeForExitCode(code); got != want {
t.Fatalf("errorTypeForExitCode(%d)=%q", code, got)
}
}
}
func TestFrameworkExecutePreparseUnifiedErrorAndEmissionFallback(t *testing.T) {
for _, failWriter := range []bool{false, true} {
t.Run(map[bool]string{false: "unified", true: "fallback"}[failWriter], func(t *testing.T) {
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "leaf"}
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
testseam.Swap(t, &rootStopAllStdioClients, func() {})
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return errors.New("bad preparse") })
var stdout bytes.Buffer
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.SetContext(ctx)
leaf := &cobra.Command{Use: "leaf"}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
if failWriter {
leaf.SetOut(frameworkFailWriter{})
} else {
leaf.SetOut(&stdout)
}
leaf.SetErr(&bytes.Buffer{})
root.AddCommand(leaf)
return root
})
if code := Execute(); code != 3 {
t.Fatalf("Execute code=%d", code)
}
if !failWriter && !strings.Contains(stdout.String(), `"outcome": "failure"`) {
t.Fatalf("stdout=%q", stdout.String())
}
})
}
}
func TestFrameworkPublicRootRequiresResultFromActiveCommand(t *testing.T) {
root := NewRootCommand(context.Background())
leaf := &cobra.Command{Use: "active-no-result", RunE: func(*cobra.Command, []string) error { return nil }}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
root.SetArgs([]string{"active-no-result"})
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "without a CommandResult") {
t.Fatalf("ExecuteC error=%v", err)
}
}
func TestFrameworkAbortOutputSinkRemoveFailure(t *testing.T) {
originalRemove := rootRemoveFile
t.Cleanup(func() { rootRemoveFile = originalRemove })
file, err := os.CreateTemp(t.TempDir(), "abort-*")
if err != nil {
t.Fatal(err)
}
rootRemoveFile = func(string) error { return errors.New("remove failed") }
cmd := &cobra.Command{Use: "abort"}
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file, tempPath: file.Name()}))
if err := abortOutputSink(cmd); err == nil || !strings.Contains(err.Error(), "remove temporary") {
t.Fatalf("abort error=%v", err)
}
}
func TestFrameworkOutputSinkHookWrappingAndCleanupEdges(t *testing.T) {
installOutputSinkRunBoundary(nil)
plain := &cobra.Command{Use: "plain"}
plain.SetContext(context.Background())
installOutputSinkRunBoundary(plain)
// newBoundaryChild builds a leaf whose --output lives on the root's
// persistent flag set, matching production wiring (a local --output flag
// belongs to the leaf's own business contract and skips the sink).
newBoundaryChild := func(outputPath string) *cobra.Command {
root := &cobra.Command{Use: "root"}
root.PersistentFlags().String("output", outputPath, "")
cmd := &cobra.Command{Use: "leaf"}
root.AddCommand(cmd)
cmd.SetContext(context.Background())
return cmd
}
var calls int
cmd := newBoundaryChild("")
cmd.RunE = func(*cobra.Command, []string) error { calls++; return nil }
cmd.PostRunE = func(*cobra.Command, []string) error { calls++; return nil }
installOutputSinkRunBoundary(cmd)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
if err := cmd.PostRunE(cmd, nil); err != nil {
t.Fatal(err)
}
runOnly := newBoundaryChild("")
runOnly.Run = func(*cobra.Command, []string) { calls++ }
runOnly.PostRun = func(*cobra.Command, []string) { calls++ }
installOutputSinkRunBoundary(runOnly)
if runOnly.Run != nil || runOnly.RunE == nil {
t.Fatal("Run-only leaf must be converted to RunE so sink setup errors surface")
}
if err := runOnly.RunE(runOnly, nil); err != nil {
t.Fatal(err)
}
runOnly.PostRun(runOnly, nil)
if calls != 4 {
t.Fatalf("hook calls=%d", calls)
}
// A sink setup failure at Run entry returns before the business hook runs.
testseam.Swap(t, &rootCreateTemp, func(string, string) (*os.File, error) { return nil, errors.New("create failed") })
failCmd := newBoundaryChild(filepath.Join(t.TempDir(), "out.txt"))
businessRan := false
failCmd.RunE = func(*cobra.Command, []string) error { businessRan = true; return nil }
installOutputSinkRunBoundary(failCmd)
if err := failCmd.RunE(failCmd, nil); err == nil || !strings.Contains(err.Error(), "create failed") {
t.Fatalf("Run entry sink setup error=%v", err)
}
if businessRan {
t.Fatal("business hook ran after sink setup failure")
}
testseam.Swap(t, &rootCreateTemp, os.CreateTemp)
// A Run entry business error aborts the open sink: the temporary file is
// removed and the final target is never created.
abortTarget := filepath.Join(t.TempDir(), "result.txt")
abortCmd := newBoundaryChild(abortTarget)
abortCmd.RunE = func(*cobra.Command, []string) error { return errors.New("boom") }
installOutputSinkRunBoundary(abortCmd)
if err := abortCmd.RunE(abortCmd, nil); err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("Run entry business error=%v", err)
}
if _, err := os.Stat(abortTarget); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("target exists after aborted run: %v", err)
}
assertNoOutputTemps(t, abortTarget)
// A second configureOutputSink call on an already-open sink (a reused
// command tree stacks one Run wrapper per ExecuteC) must not replace the
// live sink with a second temporary file.
repeatTarget := filepath.Join(t.TempDir(), "result.txt")
repeatCmd := newBoundaryChild(repeatTarget)
if err := configureOutputSink(repeatCmd); err != nil {
t.Fatal(err)
}
first := outputSinkForCommand(repeatCmd)
if first == nil {
t.Fatal("first configureOutputSink did not open a sink")
}
if err := configureOutputSink(repeatCmd); err != nil {
t.Fatal(err)
}
if second := outputSinkForCommand(repeatCmd); second != first {
t.Fatal("configureOutputSink replaced an open sink")
}
if err := abortOutputSink(repeatCmd); err != nil {
t.Fatal(err)
}
assertNoOutputTemps(t, repeatTarget)
file2, err := os.CreateTemp(t.TempDir(), "sink-error-*")
if err != nil {
t.Fatal(err)
}
errorCmd := &cobra.Command{Use: "error"}
errorCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file2, tempPath: file2.Name(), target: "unused"}))
if err := runWithOutputSinkErrorCleanup(errorCmd, func() error { return errors.New("boom") }); err == nil {
t.Fatal("run error swallowed")
}
file3, err := os.CreateTemp(t.TempDir(), "sink-panic-*")
if err != nil {
t.Fatal(err)
}
panicCmd := &cobra.Command{Use: "panic"}
panicCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file3, tempPath: file3.Name(), target: "unused"}))
func() {
defer func() {
if recover() == nil {
t.Fatal("panic swallowed")
}
}()
_ = runWithOutputSinkErrorCleanup(panicCmd, func() error { panic("boom") })
}()
if closeOutputSink(nil) != nil || abortOutputSink(nil) != nil || outputSinkForCommand(nil) != nil {
t.Fatal("nil sink guards failed")
}
finished := &outputSinkState{finished: true, file: file3}
finishedCmd := &cobra.Command{Use: "finished"}
finishedCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, finished))
if closeOutputSink(finishedCmd) != nil || abortOutputSink(finishedCmd) != nil {
t.Fatal("finished sink was processed twice")
}
}
type frameworkFailWriter struct{}
func (frameworkFailWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
func TestFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
for _, failWriter := range []bool{false, true} {
t.Run(map[bool]string{false: "emits", true: "fallback"}[failWriter], func(t *testing.T) {
testseam.Protect(t, &os.Args)
os.Args = []string{"dws"}
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
testseam.Swap(t, &rootStopAllStdioClients, func() {})
var stdout bytes.Buffer
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
cmd := &cobra.Command{Use: "dws"}
cmd.SetContext(ctx)
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
if failWriter {
cmd.SetOut(frameworkFailWriter{})
} else {
cmd.SetOut(&stdout)
}
cmd.SetErr(&bytes.Buffer{})
return cmd
})
testseam.Swap(t, &rootExecuteCommand, func(*cobra.Command) (*cobra.Command, error) { panic("before emission") })
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
if !failWriter && !strings.Contains(stdout.String(), `"outcome": "failure"`) {
t.Fatalf("stdout=%q", stdout.String())
}
})
}
}
func TestCrossPlatformCoverageFrameworkExecuteRareOutcomeBranches(t *testing.T) {
t.Run("preparse interrupted", func(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootRunPreParse, func(cmd *cobra.Command, _ *pipeline.Engine) error {
signalSelf(t, syscall.SIGINT)
<-cmd.Context().Done()
return errors.New("preparse failed")
})
if code := Execute(); code != 130 {
t.Fatalf("Execute code=%d", code)
}
})
t.Run("nil executed after emission attempt", func(t *testing.T) {
installSignalExecuteSeams(t, true, io.Discard, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
cmd.SetOut(frameworkFailWriter{})
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
_, _, _ = output.EmitStoredResult(cmd)
signalSelf(t, syscall.SIGINT)
<-cmd.Context().Done()
return nil, cmd.Context().Err()
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
})
t.Run("publication failure after emission", func(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
if _, _, err := output.EmitStoredResult(cmd); err != nil {
t.Fatal(err)
}
return cmd, newOutputPublicationError("publish", errors.New("rename failed"))
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
})
t.Run("publication failure envelope writer also fails", func(t *testing.T) {
installSignalExecuteSeams(t, true, io.Discard, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
if _, _, err := output.EmitStoredResult(cmd); err != nil {
t.Fatal(err)
}
file, err := os.CreateTemp(t.TempDir(), "finished-output-*")
if err != nil {
t.Fatal(err)
}
defer file.Close()
state := &outputSinkState{file: file, original: frameworkFailWriter{}, finished: true}
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, state))
return cmd, newOutputPublicationError("publish", errors.New("rename failed"))
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
})
t.Run("failure envelope cannot be written", func(t *testing.T) {
installSignalExecuteSeams(t, true, io.Discard, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
cmd.SetOut(frameworkFailWriter{})
return cmd, errors.New("business failed")
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
})
t.Run("late output publication warning", func(t *testing.T) {
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
file, err := os.CreateTemp(t.TempDir(), "late-output-*")
if err != nil {
t.Fatal(err)
}
state := &outputSinkState{file: file, tempPath: file.Name(), target: filepath.Join(t.TempDir(), "result.json")}
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, state))
return cmd, nil
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
})
for _, tc := range []struct {
name string
unified bool
original io.Writer
wantOutput bool
}{
{name: "unified late publication failure", unified: true, original: &bytes.Buffer{}, wantOutput: true},
{name: "legacy late publication failure", original: io.Discard},
{name: "late publication failure writer fails", unified: true, original: frameworkFailWriter{}},
} {
t.Run(tc.name, func(t *testing.T) {
installSignalExecuteSeams(t, tc.unified, io.Discard, io.Discard)
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
var original io.Writer = tc.original
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
file, err := os.CreateTemp(t.TempDir(), "panic-output-*")
if err != nil {
t.Fatal(err)
}
cmd.SetOut(file)
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{
file: file, original: original, tempPath: file.Name(), target: filepath.Join(t.TempDir(), "result.json"),
}))
panic("after sink open")
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
if tc.wantOutput && !strings.Contains(tc.original.(*bytes.Buffer).String(), `"outcome": "failure"`) {
t.Fatalf("stdout=%q", tc.original.(*bytes.Buffer).String())
}
})
}
t.Run("abort failure is diagnostic", func(t *testing.T) {
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
file, err := os.CreateTemp(t.TempDir(), "abort-output-*")
if err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{
file: file, original: io.Discard, tempPath: file.Name(), target: filepath.Join(t.TempDir(), "result.json"),
}))
return cmd, errors.New("business failed")
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
})
t.Run("publication helper requires observable finished transaction", func(t *testing.T) {
cmd := &cobra.Command{Use: "unified"}
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
file, err := os.CreateTemp(t.TempDir(), "unfinished-output-*")
if err != nil {
t.Fatal(err)
}
defer file.Close()
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
file: file, finished: true,
}))
if _, handled, emitErr := emitOutputPublicationFailure(cmd, newOutputPublicationError("publish", errors.New("rename failed"))); handled || emitErr != nil {
t.Fatalf("handled=%v err=%v", handled, emitErr)
}
})
}
type frameworkPanicWriter struct{}
func (frameworkPanicWriter) Write([]byte) (int, error) { panic("writer panic") }
func TestCrossPlatformCoverageFrameworkRootHookErrors(t *testing.T) {
t.Run("flag group validation", func(t *testing.T) {
root := NewRootCommand(context.Background())
leaf := &cobra.Command{Use: "exclusive", RunE: func(*cobra.Command, []string) error { return nil }}
leaf.Flags().Bool("left", false, "")
leaf.Flags().Bool("right", false, "")
leaf.MarkFlagsMutuallyExclusive("left", "right")
root.AddCommand(leaf)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"exclusive", "--left", "--right"})
if err := root.Execute(); err == nil {
t.Fatal("expected mutually-exclusive flag error")
}
})
t.Run("edition pre-run error", func(t *testing.T) {
old := edition.Get()
t.Cleanup(func() { edition.Override(old) })
edition.Override(&edition.Hooks{AfterPersistentPreRun: func(*cobra.Command, []string) error {
return errors.New("edition hook failed")
}})
root := NewRootCommand(context.Background())
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"version"})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "edition hook failed") {
t.Fatalf("Execute error=%v", err)
}
})
t.Run("post-run emission panic", func(t *testing.T) {
root := NewRootCommand(context.Background())
cmd := &cobra.Command{Use: "panic-output"}
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
cmd.SetOut(frameworkPanicWriter{})
if err := output.StoreResult(ctx, output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
defer func() {
if recover() == nil {
t.Fatal("expected post-run panic")
}
}()
_ = root.PersistentPostRunE(cmd, nil)
})
}
+498 -26
View File
@@ -15,24 +15,24 @@ package app
import (
"context"
"encoding/json"
stderrors "errors"
"fmt"
"io"
"log/slog"
"net/url"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
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/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
@@ -40,6 +40,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -58,8 +59,14 @@ var (
rootStopAllStdioClients = StopAllStdioClients
rootLoadPlugins = loadPlugins
rootMkdirAll = os.MkdirAll
rootCreateFile = os.Create
rootCreateTemp = os.CreateTemp
rootSyncFile = (*os.File).Sync
rootCloseFile = (*os.File).Close
// os.Rename replaces an existing non-directory target on every supported
// Go host; the Windows implementation uses MOVEFILE_REPLACE_EXISTING. Keep
// the temporary file beside the target so publication stays on one volume.
rootRenameFile = os.Rename
rootRemoveFile = os.Remove
rootPluginInjectConfigEnv = (*plugin.Loader).InjectPluginConfigEnv
rootPluginLoadUser = (*plugin.Loader).LoadUser
rootPluginLoadDev = (*plugin.Loader).LoadDev
@@ -76,10 +83,53 @@ var (
// Execute runs the root command and returns the process exit code.
func Execute() (exitCode int) {
var (
root *cobra.Command
executed *cobra.Command
resultStore *output.ResultStore
)
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
exitCode = 5
target := executed
if target == nil && root != nil {
if found, _, err := root.Find(os.Args[1:]); err == nil {
target = found
}
}
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
exitCode = code
if target != nil {
fmt.Fprintf(target.ErrOrStderr(), "Warning: command panicked after result emission attempt: %v\n", r)
}
} else if target != nil && output.UsesUnifiedResult(target) {
info := &output.ErrorInfo{Type: "internal", ExitCode: 5, Message: fmt.Sprintf("internal panic: %v", r)}
if code, err := output.EmitResult(target, output.Failure(info)); err == nil {
exitCode = code
} else {
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
exitCode = 5
}
} else {
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
exitCode = 5
}
if executed == nil {
executed = target
}
}
CloseFileLogger()
if executed != nil {
if err := closeOutputSink(executed); err != nil {
if code, handled, emitErr := emitOutputPublicationFailure(executed, err); handled && emitErr == nil {
exitCode = code
} else {
exitCode = apperrors.ExitCode(err)
fmt.Fprintf(os.Stderr, "Warning: close output sink: %v\n", err)
if emitErr != nil {
fmt.Fprintf(os.Stderr, "Warning: emit output publication failure: %v\n", emitErr)
}
}
}
}
}()
@@ -95,15 +145,17 @@ func Execute() (exitCode int) {
timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args))
}()
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
// Attach timing collector to context for use by child components
ctx = WithTimingCollector(ctx, timing)
ctx := WithTimingCollector(context.Background(), timing)
ctx, resultStore = output.WithResultStore(ctx)
var signalState *processSignalState
var stopSignals func()
ctx, signalState, stopSignals = installProcessSignalContext(ctx, resultStore)
defer stopSignals()
initStart := time.Now()
engine := newPipelineEngine()
root := rootNewRootCommandWithEngine(ctx, engine)
root = rootNewRootCommandWithEngine(ctx, engine)
timing.Record("cmd_init", time.Since(initStart))
// Run PreParse handlers on raw argv before Cobra parses flags.
@@ -111,16 +163,89 @@ func Execute() (exitCode int) {
// and --limit100 → --limit 100.
if err := rootRunPreParse(root, engine); err != nil {
err = newPreParseValidationError(err)
if interrupted, _ := signalState.outcome(); interrupted != nil {
err = interrupted
}
if target, _, findErr := root.Find(os.Args[1:]); findErr == nil && target != nil && output.UsesUnifiedResult(target) {
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
code, emitErr := output.EmitResult(target, result)
if emitErr == nil {
return code
}
}
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
return apperrors.ExitCode(err)
}
executed, err := rootExecuteCommand(root)
var err error
executed, err = rootExecuteCommand(root)
// PersistentPostRunE normally commits or aborts the transactional output
// sink. Finalize once more at the process boundary so custom execution
// seams, embedding callers, or future hook changes cannot leave publication
// errors to a defer that runs after the process exit code is fixed.
if executed != nil {
if err == nil {
if closeErr := closeOutputSink(executed); closeErr != nil {
err = closeErr
}
} else if abortErr := abortOutputSink(executed); abortErr != nil {
fmt.Fprintf(executed.ErrOrStderr(), "Warning: abort output sink after command failure: %v\n", abortErr)
}
}
interrupted, primaryCompletedBeforeSignal := signalState.outcome()
if interrupted != nil && !primaryCompletedBeforeSignal {
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
var publicationErr *outputPublicationError
if err != nil && stderrors.As(err, &publicationErr) {
// The successful result was written only to a transaction that did
// not publish. Let the error path replace it with one observable
// failure envelope on the restored original stream.
} else {
if executed == nil {
executed = root
}
fmt.Fprintf(executed.ErrOrStderr(), "Warning: process interrupted after result emission attempt: %v\n", interrupted)
// Once publication starts, its stored exit code is authoritative. A
// signal recorded just before or during publication must not turn a
// successfully emitted result into a contradictory 130/143 process
// status; likewise, a failed publication must retain its internal
// error code instead of being relabelled as cancellation.
return code
}
}
var publicationErr *outputPublicationError
if err == nil || !stderrors.As(err, &publicationErr) {
err = interrupted
}
}
if err != nil {
if executed == nil {
executed = root
}
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
var publicationErr *outputPublicationError
if stderrors.As(err, &publicationErr) {
if failureCode, handled, emitErr := emitOutputPublicationFailure(executed, publicationErr); handled {
if emitErr == nil {
return failureCode
}
fmt.Fprintf(executed.ErrOrStderr(), "Warning: emit output publication failure: %v\n", emitErr)
}
return apperrors.ExitCode(publicationErr)
}
fmt.Fprintf(executed.ErrOrStderr(), "Warning: command hook failed after result emission: %v\n", err)
return code
}
err = rewordRequiredFlagError(err)
var raw apperrors.RawStderrError
if output.UsesUnifiedResult(executed) && !stderrors.As(err, &raw) {
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
code, emitErr := output.EmitResult(executed, result)
if emitErr == nil {
return code
}
err = apperrors.NewInternal("emit failure result: "+emitErr.Error(), apperrors.WithCause(emitErr))
}
if isUnknownCommandError(err) {
executed.SetOut(os.Stderr)
_ = executed.Help()
@@ -129,9 +254,121 @@ func Execute() (exitCode int) {
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
return apperrors.ExitCode(err)
}
if code, emitted := output.StoredExitCode(resultStore); emitted {
return code
}
return 0
}
// errorInfoFromExecutionError projects the repository error model into the unified
// failure body. Exit code and category are derived from the same error value,
// preventing the wire and process status from drifting apart.
func errorInfoFromExecutionError(err error) *output.ErrorInfo {
exitCode := apperrors.ExitCode(err)
info := &output.ErrorInfo{
Type: errorTypeForExitCode(exitCode),
ExitCode: exitCode,
Message: err.Error(),
}
var interrupted *processInterruption
if stderrors.As(err, &interrupted) && interrupted != nil {
info.Type = "internal"
info.Subtype = interrupted.Subtype()
return info
}
if stderrors.Is(err, context.DeadlineExceeded) {
info.Subtype = "deadline_exceeded"
}
var cliErr *helpers.CLIError
if stderrors.As(err, &cliErr) && cliErr != nil {
info.UpstreamCode = cliErr.Code
info.Hint = cliErr.Suggestion
info.Operation = cliErr.Operation
}
var callErr *transport.CallError
if stderrors.As(err, &callErr) && callErr != nil {
info.HTTPStatus = callErr.HTTPStatus
info.RPCCode = callErr.RPCCode
info.Stage = string(callErr.Stage)
if callErr.RequestID != "" {
info.RequestID = callErr.RequestID
} else if callErr.TraceID != "" {
info.RequestID = callErr.TraceID
}
}
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed == nil {
return info
}
if typed.Category == apperrors.CategoryPartial {
// An error lacks the item-level data required by partial_failure.
// Callers must use output.Partial; fail closed consistently otherwise.
info.Type = string(apperrors.CategoryInternal)
} else {
info.Type = string(typed.Category)
}
info.Subtype = typed.Reason
if typed.Hint != "" {
info.Hint = typed.Hint
}
info.Actions = append([]string(nil), typed.Actions...)
info.Retryable = typed.RetryableSet && typed.Retryable
info.RetryAfterSeconds = typed.RetryAfterSeconds
if typed.RPCCode != 0 {
info.RPCCode = typed.RPCCode
}
if typed.ServerDiag.TraceID != "" {
info.TraceID = typed.ServerDiag.TraceID
}
if typed.Operation != "" {
info.Operation = typed.Operation
}
info.ServerKey = typed.ServerKey
info.Origin = typed.Origin
if typed.FailureStage != "" {
info.Stage = typed.FailureStage
}
info.ExecutionStarted = typed.ExecutionStarted
if typed.NextRetryAt != nil {
info.NextRetryAt = typed.NextRetryAt.UTC().Format(time.RFC3339)
}
info.AvailableFlags = append([]string(nil), typed.AvailableFlags...)
info.SnapshotPath = typed.Snapshot
info.Details = typed.Details
if len(typed.RPCData) > 0 {
var rpcData any
if json.Unmarshal(typed.RPCData, &rpcData) == nil {
info.RPCData = rpcData
}
}
info.TechnicalDetail = typed.ServerDiag.TechnicalDetail
info.FriendlyHint, info.ActionURL = apperrors.ServerGuidance(typed.ServerDiag)
if typed.Cause != nil {
info.Cause = typed.Cause.Error()
}
if typed.ServerDiag.ServerErrorCode != "" {
info.UpstreamCode = typed.ServerDiag.ServerErrorCode
}
return info
}
func errorTypeForExitCode(code int) string {
switch code {
case 1:
return "api"
case 2:
return "auth"
case 3:
return "validation"
case 4:
return "permission"
case 6:
return "discovery"
default:
return "internal"
}
}
// newPreParseValidationError keeps pipeline handler identity in internal logs
// while exposing only the underlying parameter-domain error to CLI users.
func newPreParseValidationError(err error) error {
@@ -368,6 +605,7 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0]
}
rootCtx, _ = output.WithResultStore(rootCtx)
return newRootCommandWithEngine(rootCtx, nil, true, false)
}
@@ -390,6 +628,7 @@ func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
// no pipeline processing is applied.
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
registerSchemaRuntimeDelivery()
rootCtx, _ = output.WithResultStore(rootCtx)
return newRootCommandWithEngine(rootCtx, engine, true, false)
}
@@ -414,6 +653,25 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
return cmd.Help()
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// A public root may be reused by embedding callers through multiple
// ExecuteC invocations. Begin each invocation with an empty result
// lifecycle while retaining the store pointer observed by Execute's
// signal and exit-code handling. Declaration-only command trees do not
// install a store at construction time, so add one lazily when those
// trees are executed for compatibility and policy tests.
executionCtx, _ := output.WithResultStore(cmd.Context())
cmd.SetContext(executionCtx)
// WithResultStore above guarantees the reset precondition.
_ = output.ResetResultStore(executionCtx)
// Do not run Cobra's ValidateRequiredFlags/ValidateFlagGroups here:
// Cobra executes them between the leaf's PreRunE and RunE, and leaves
// rely on that order to normalize alias flags into required canonical
// flags (for example chat message download-media copies --msg-id into
// the required --message-id in PreRunE). Running them early fails the
// alias path before the leaf can normalize it. The transactional
// --output sink instead opens at Run entry (after Cobra's own
// validation), so validation failures still cannot strand a
// temporary file.
// Validate caller-provided identity labels before any edition hook
// or command network activity can run. Header-only library callers
// use the best-effort path in resolveIdentityHeaders instead.
@@ -436,19 +694,37 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
// Configure global slog level based on --debug / --verbose flags.
configureLogLevel(flags)
if err := configureOutputSink(cmd); err != nil {
return err
}
installOutputSinkRunBoundary(cmd)
if fn := edition.Get().AfterPersistentPreRun; fn != nil {
return fn(cmd, args)
if err := fn(cmd, args); err != nil {
return err
}
}
return nil
},
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
PersistentPostRunE: func(cmd *cobra.Command, args []string) (err error) {
defer func() {
if r := recover(); r != nil {
warnAbortOutputSink(cmd)
panic(r)
}
if err != nil {
warnAbortOutputSink(cmd)
}
}()
_, emitted, emitErr := output.EmitStoredResult(cmd)
StopAllStdioClients()
CloseAuditSink()
CloseFileLogger()
return closeOutputSink(cmd)
if emitErr != nil {
return apperrors.NewInternal("emit command result: "+emitErr.Error(), apperrors.WithCause(emitErr))
}
if output.UsesUnifiedResult(cmd) && !emitted {
return apperrors.NewInternal("framework 2.0 command returned without a CommandResult")
}
if closeErr := closeOutputSink(cmd); closeErr != nil {
return closeErr
}
return nil
},
}
@@ -472,7 +748,7 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
newConfigCommand(),
newDoctorCommand(),
newRecoveryCommand(),
newEventCommand(),
newEventCommand(flags),
newAuditCommand(),
newCompletionCommand(root),
newUpgradeCommand(),
@@ -848,6 +1124,54 @@ func deduplicateCommands(root *cobra.Command) {
}
}
type outputSinkState struct {
mu sync.Mutex
file *os.File
original io.Writer
tempPath string
target string
finished bool
}
type outputPublicationError struct {
cause error
}
func (e *outputPublicationError) Error() string { return e.cause.Error() }
func (e *outputPublicationError) Unwrap() error { return e.cause }
func (e *outputPublicationError) ExitCode() int { return 5 }
func newOutputPublicationError(message string, cause error) error {
return &outputPublicationError{cause: fmt.Errorf("%s: %w", message, cause)}
}
// emitOutputPublicationFailure replaces a result that was rendered only into a
// rolled-back transactional file with one observable failure envelope on the
// original output stream. This is not a second public result: closeOutputSink
// has removed the temporary file and restored cmd.OutOrStdout before returning
// the publication error.
func emitOutputPublicationFailure(cmd *cobra.Command, err error) (code int, handled bool, emitErr error) {
var publicationErr *outputPublicationError
if cmd == nil || !stderrors.As(err, &publicationErr) || !output.UsesUnifiedResult(cmd) {
return 0, false, nil
}
state := outputSinkForCommand(cmd)
if state == nil {
return 0, false, nil
}
state.mu.Lock()
original := state.original
finished := state.finished
state.mu.Unlock()
if original == nil || !finished {
return 0, false, nil
}
cmd.SetOut(original)
result := output.FailureWithExitCode(errorInfoFromExecutionError(publicationErr), apperrors.ExitCode(publicationErr))
code, emitErr = output.EmitResult(cmd, result)
return code, true, emitErr
}
func configureOutputSink(cmd *cobra.Command) error {
if local := cmd.LocalFlags().Lookup("output"); local != nil {
return nil
@@ -860,32 +1184,180 @@ func configureOutputSink(cmd *cobra.Command) error {
if outputPath == "" {
return nil
}
// A public root may be reused across ExecuteC calls, accumulating one Run
// wrapper per execution. When the sink for this invocation is already open,
// an inner wrapper must not replace it with a second temporary file.
if state := outputSinkForCommand(cmd); state != nil {
state.mu.Lock()
finished := state.finished
state.mu.Unlock()
if !finished {
return nil
}
}
if err := validateOptionalPath("--output", outputPath); err != nil {
return err
}
if err := rootMkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to prepare output directory: %v", err))
}
file, err := rootCreateFile(outputPath)
tempPattern := "." + filepath.Base(outputPath) + ".tmp-*"
file, err := rootCreateTemp(filepath.Dir(outputPath), tempPattern)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to create output file: %v", err))
return apperrors.NewInternal(fmt.Sprintf("failed to create temporary output file: %v", err))
}
originalOut := cmd.OutOrStdout()
cmd.SetOut(file)
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, file))
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{
file: file,
original: originalOut,
tempPath: file.Name(),
target: outputPath,
}))
return nil
}
// installOutputSinkRunBoundary defers opening the transactional --output sink
// to the executed command's Run entry. Cobra runs ValidateRequiredFlags and
// ValidateFlagGroups after the leaf's PreRunE and immediately before RunE, so
// opening the sink there keeps two invariants at once: leaf PreRunE hooks can
// still normalize alias flags into required canonical flags, and a validation
// failure can never strand a temporary output file. Run-only leaves are
// converted to RunE so a sink setup failure remains a returned error. Post-run
// hooks keep the error cleanup wrapping so a post-run failure still aborts the
// transaction; pre-run hooks need no wrapping because the sink cannot exist
// before Run entry.
func installOutputSinkRunBoundary(cmd *cobra.Command) {
if cmd == nil {
return
}
openSinkAndRun := func(run func(*cobra.Command, []string) error) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
if err := configureOutputSink(cmd); err != nil {
return err
}
return runWithOutputSinkErrorCleanup(cmd, func() error { return run(cmd, args) })
}
}
if cmd.RunE != nil {
cmd.RunE = openSinkAndRun(cmd.RunE)
} else if cmd.Run != nil {
original := cmd.Run
cmd.Run = nil
cmd.RunE = openSinkAndRun(func(cmd *cobra.Command, args []string) error {
original(cmd, args)
return nil
})
}
if cmd.PostRunE != nil {
original := cmd.PostRunE
cmd.PostRunE = func(cmd *cobra.Command, args []string) error {
return runWithOutputSinkErrorCleanup(cmd, func() error { return original(cmd, args) })
}
}
if cmd.PostRun != nil {
original := cmd.PostRun
cmd.PostRun = func(cmd *cobra.Command, args []string) {
_ = runWithOutputSinkErrorCleanup(cmd, func() error {
original(cmd, args)
return nil
})
}
}
}
func runWithOutputSinkErrorCleanup(cmd *cobra.Command, run func() error) (err error) {
defer func() {
if r := recover(); r != nil {
warnAbortOutputSink(cmd)
panic(r)
}
if err != nil {
warnAbortOutputSink(cmd)
}
}()
return run()
}
func warnAbortOutputSink(cmd *cobra.Command) {
if closeErr := abortOutputSink(cmd); closeErr != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: close output sink: %v\n", closeErr)
}
}
func closeOutputSink(cmd *cobra.Command) error {
file, ok := cmd.Context().Value(outputFileContextKey{}).(*os.File)
if !ok || file == nil {
state := outputSinkForCommand(cmd)
if state == nil {
return nil
}
if err := rootCloseFile(file); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", err))
state.mu.Lock()
defer state.mu.Unlock()
// A reusable Cobra tree must never retain the transactional file as its
// stdout after this execution. Restore the caller's writer on every terminal
// path, including sync/close/rename failures and repeated cleanup calls.
if state.original != nil {
cmd.SetOut(state.original)
}
if state.finished {
return nil
}
state.finished = true
if err := rootSyncFile(state.file); err != nil {
_ = rootCloseFile(state.file)
_ = rootRemoveFile(state.tempPath)
return newOutputPublicationError("failed to sync output file", err)
}
if err := rootCloseFile(state.file); err != nil {
_ = rootRemoveFile(state.tempPath)
return newOutputPublicationError("failed to close output file", err)
}
if err := rootRenameFile(state.tempPath, state.target); err != nil {
_ = rootRemoveFile(state.tempPath)
return newOutputPublicationError("failed to publish output file", err)
}
return nil
}
func abortOutputSink(cmd *cobra.Command) error {
state := outputSinkForCommand(cmd)
if state == nil {
return nil
}
state.mu.Lock()
defer state.mu.Unlock()
if state.finished {
return nil
}
state.finished = true
// A business error still needs the root execution boundary to publish one
// typed failure envelope. Restore the pre-transaction writer before closing
// and unlinking the temporary file so that failure emission cannot target a
// closed descriptor. The final --output target remains untouched.
if state.original != nil {
cmd.SetOut(state.original)
}
closeErr := rootCloseFile(state.file)
removeErr := rootRemoveFile(state.tempPath)
if closeErr != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", closeErr))
}
if removeErr != nil && !stderrors.Is(removeErr, os.ErrNotExist) {
return apperrors.NewInternal(fmt.Sprintf("failed to remove temporary output file: %v", removeErr))
}
return nil
}
func outputSinkForCommand(cmd *cobra.Command) *outputSinkState {
if cmd == nil || cmd.Context() == nil {
return nil
}
state, _ := cmd.Context().Value(outputFileContextKey{}).(*outputSinkState)
if state == nil || state.file == nil {
return nil
}
return state
}
func validateOptionalPath(flagName, path string) error {
path = strings.TrimSpace(path)
if path == "" {
+10 -6
View File
@@ -157,11 +157,11 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
})
oldMkdir := rootMkdirAll
oldCreate := rootCreateFile
oldCreate := rootCreateTemp
oldClose := rootCloseFile
t.Cleanup(func() {
rootMkdirAll = oldMkdir
rootCreateFile = oldCreate
rootCreateTemp = oldCreate
rootCloseFile = oldClose
})
wantErr := errors.New("filesystem")
@@ -193,17 +193,19 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
t.Fatal("mkdir failure succeeded")
}
rootMkdirAll = func(string, os.FileMode) error { return nil }
rootCreateFile = func(string) (*os.File, error) { return nil, wantErr }
rootCreateTemp = func(string, string) (*os.File, error) { return nil, wantErr }
if err := configureOutputSink(newOutputCommand(filepath.Join("create-failure", "out"))); err == nil {
t.Fatal("create failure succeeded")
}
rootCreateFile = oldCreate
rootCreateTemp = oldCreate
file, err := os.CreateTemp(t.TempDir(), "close")
if err != nil {
t.Fatal(err)
}
cmd := &cobra.Command{Use: "close"}
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file))
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
file: file, tempPath: file.Name(), target: filepath.Join(filepath.Dir(file.Name()), "close-target"),
}))
rootCloseFile = func(*os.File) error { return wantErr }
if err := closeOutputSink(cmd); err == nil {
t.Fatal("close failure succeeded")
@@ -216,7 +218,9 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
if err != nil {
t.Fatal(err)
}
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file))
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
file: file, tempPath: file.Name(), target: filepath.Join(filepath.Dir(file.Name()), "close-success-target"),
}))
if err := closeOutputSink(cmd); err != nil {
t.Fatalf("close success = %v", err)
}
@@ -0,0 +1,240 @@
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/spf13/cobra"
)
func TestPublicRootDirectExecuteResetsUnifiedResultLifecycle(t *testing.T) {
root := NewRootCommand(context.Background())
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&bytes.Buffer{})
run := 0
leaf := &cobra.Command{
Use: "lifecycle-repeat",
RunE: func(cmd *cobra.Command, _ []string) error {
run++
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"run": run}))
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
for want := 1; want <= 2; want++ {
stdout.Reset()
root.SetArgs([]string{"lifecycle-repeat", "--format", "json"})
executed, err := root.ExecuteC()
if err != nil {
t.Fatalf("ExecuteC run %d: %v", want, err)
}
if executed != leaf {
t.Fatalf("ExecuteC run %d executed %v, want lifecycle leaf", want, executed)
}
var envelope struct {
OK bool `json:"ok"`
Data struct {
Run int `json:"run"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("ExecuteC run %d output %q: %v", want, stdout.String(), err)
}
if !envelope.OK || envelope.Data.Run != want {
t.Fatalf("ExecuteC run %d envelope=%+v", want, envelope)
}
}
missing := &cobra.Command{Use: "lifecycle-missing", RunE: func(*cobra.Command, []string) error { return nil }}
output.SetCommandRollout(missing, output.RolloutUnifiedActive)
root.AddCommand(missing)
stdout.Reset()
root.SetArgs([]string{"lifecycle-missing", "--format", "json"})
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "without a CommandResult") {
t.Fatalf("missing-result ExecuteC error=%v, want fresh lifecycle failure", err)
}
if stdout.Len() != 0 {
t.Fatalf("missing-result ExecuteC replayed stale output %q", stdout.String())
}
}
func TestPublicRootRestoresStdoutAfterSuccessfulOutputPublication(t *testing.T) {
root := NewRootCommand(context.Background())
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&bytes.Buffer{})
run := 0
leaf := &cobra.Command{
Use: "lifecycle-output-repeat",
RunE: func(cmd *cobra.Command, _ []string) error {
run++
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"run": run}))
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
target := filepath.Join(t.TempDir(), "result.json")
root.SetArgs([]string{"lifecycle-output-repeat", "--output", target, "--format", "json"})
if _, err := root.ExecuteC(); err != nil {
t.Fatalf("first ExecuteC: %v", err)
}
first, err := os.ReadFile(target)
if err != nil || !bytes.Contains(first, []byte(`"run": 1`)) {
t.Fatalf("published output=%q err=%v", first, err)
}
if err := root.PersistentFlags().Set("output", ""); err != nil {
t.Fatal(err)
}
stdout.Reset()
root.SetArgs([]string{"lifecycle-output-repeat", "--format", "json"})
if _, err := root.ExecuteC(); err != nil {
t.Fatalf("second ExecuteC: %v", err)
}
if !strings.Contains(stdout.String(), `"run": 2`) {
t.Fatalf("second stdout=%q", stdout.String())
}
}
func TestPublicRootDirectExecuteFailsWhenUnifiedSinkCannotPublish(t *testing.T) {
oldClose := rootCloseFile
t.Cleanup(func() { rootCloseFile = oldClose })
closeCalls := 0
rootCloseFile = func(file *os.File) error {
closeCalls++
if err := file.Close(); err != nil {
return err
}
return errors.New("late close diagnostic")
}
root := NewRootCommand(context.Background())
leaf := &cobra.Command{
Use: "lifecycle-unified",
RunE: func(cmd *cobra.Command, _ []string) error {
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
root.SetArgs([]string{"lifecycle-unified", "--output", filepath.Join(t.TempDir(), "result.json")})
executed, err := root.ExecuteC()
if err == nil || apperrors.ExitCode(err) != 5 {
t.Fatalf("direct ExecuteC error=%v, want publication failure with exit 5", err)
}
if executed != leaf {
t.Fatalf("executed=%v, want lifecycle leaf", executed)
}
if closeCalls != 1 {
t.Fatalf("output sink close calls=%d, want 1", closeCalls)
}
}
func TestPublicRootDirectExecutePreservesLegacyCloseError(t *testing.T) {
oldClose := rootCloseFile
t.Cleanup(func() { rootCloseFile = oldClose })
rootCloseFile = func(file *os.File) error {
_ = file.Close()
return errors.New("legacy close failed")
}
root := NewRootCommandWithEngine(context.Background(), nil)
root.AddCommand(&cobra.Command{Use: "lifecycle-legacy", RunE: func(*cobra.Command, []string) error { return nil }})
root.SetArgs([]string{"lifecycle-legacy", "--output", filepath.Join(t.TempDir(), "result.txt")})
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "legacy close failed") {
t.Fatalf("legacy direct ExecuteC error=%v, want close failure", err)
}
}
func TestPublicRootDirectExecuteClosesSinkOnHandlerError(t *testing.T) {
oldClose := rootCloseFile
t.Cleanup(func() { rootCloseFile = oldClose })
closeCalls := 0
rootCloseFile = func(file *os.File) error {
closeCalls++
return file.Close()
}
root := NewRootCommand(context.Background())
root.AddCommand(&cobra.Command{Use: "lifecycle-error", RunE: func(*cobra.Command, []string) error {
return errors.New("handler failed")
}})
root.SetArgs([]string{"lifecycle-error", "--output", filepath.Join(t.TempDir(), "result.txt")})
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "handler failed") {
t.Fatalf("direct ExecuteC error=%v, want handler failure", err)
}
if closeCalls != 1 {
t.Fatalf("output sink close calls=%d, want 1", closeCalls)
}
}
func TestExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
rootNormalizeProcessProfileArgs = oldNormalize
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootStopAllStdioClients = func() {}
var stdout, stderr bytes.Buffer
rootNewRootCommandWithEngine = func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetContext(ctx)
return cmd
}
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) {
result := output.Failure(&output.ErrorInfo{Type: "validation", Message: "bad input"})
if err := output.StoreResult(cmd.Context(), result); err != nil {
t.Fatal(err)
}
if _, _, err := output.EmitStoredResult(cmd); err != nil {
t.Fatal(err)
}
panic("after emission")
}
if code := Execute(); code != 3 {
t.Fatalf("Execute code=%d, want emitted validation code 3", code)
}
if got := strings.Count(stdout.String(), `"outcome": "failure"`); got != 1 {
t.Fatalf("stdout contains %d envelopes, want one: %s", got, stdout.String())
}
if !strings.Contains(stderr.String(), "panicked after result emission attempt") {
t.Fatalf("panic diagnostic missing: %q", stderr.String())
}
}
func TestErrorInfoProjectionKeepsTraceIDDistinctFromRequestID(t *testing.T) {
err := apperrors.NewAPI("failed", apperrors.WithTraceID("trace-1"))
info := errorInfoFromExecutionError(err)
if info.TraceID != "trace-1" || info.RequestID != "" {
t.Fatalf("projection trace_id=%q request_id=%q", info.TraceID, info.RequestID)
}
}
+377
View File
@@ -0,0 +1,377 @@
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func TestOutputSinkAtomicallyReplacesExistingTargetWithMode0600(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o644); err != nil {
t.Fatal(err)
}
var tempMode os.FileMode
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
info, err := cmd.OutOrStdout().(*os.File).Stat()
if err != nil {
return err
}
tempMode = info.Mode().Perm()
_, err = fmt.Fprint(cmd.OutOrStdout(), "replacement")
return err
})
root.SetArgs([]string{"atomic-output", "--output", target})
if _, err := root.ExecuteC(); err != nil {
t.Fatalf("ExecuteC: %v", err)
}
assertOutputFile(t, target, "replacement", 0o600)
if tempMode != 0o600 {
t.Fatalf("temporary output mode=%#o, want 0600", tempMode)
}
assertNoOutputTemps(t, target)
}
func TestOutputSinkHandlerFailurePreservesTarget(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
t.Fatal(err)
}
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
_, _ = fmt.Fprint(cmd.OutOrStdout(), "partial")
return errors.New("handler failed")
})
root.SetArgs([]string{"atomic-output", "--output", target})
if _, err := root.ExecuteC(); err == nil {
t.Fatal("ExecuteC succeeded")
}
assertOutputFile(t, target, "original", 0o640)
assertNoOutputTemps(t, target)
}
func TestExecuteUnifiedRunEFailureWithOutputRestoresStdoutAndPreservesTarget(t *testing.T) {
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "atomic-output-unified-failure", "--output", filepath.Join(t.TempDir(), "result.json"), "--format", "json"}
target := os.Args[3]
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
testseam.Swap(t, &rootStopAllStdioClients, func() {})
var stdout, stderr bytes.Buffer
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, engine *pipeline.Engine) *cobra.Command {
root := NewRootCommandWithEngine(ctx, engine)
root.SetOut(&stdout)
root.SetErr(&stderr)
leaf := &cobra.Command{
Use: "atomic-output-unified-failure",
RunE: func(*cobra.Command, []string) error {
return apperrors.NewValidation("business validation failed")
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
return root
})
if code := Execute(); code != 3 {
t.Fatalf("Execute exit code=%d, want validation code 3; stderr=%q", code, stderr.String())
}
var envelope struct {
OK bool `json:"ok"`
Outcome string `json:"outcome"`
Error struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("failure stdout=%q: %v; stderr=%q", stdout.String(), err, stderr.String())
}
if envelope.OK || envelope.Outcome != "failure" || envelope.Error.Type != "validation" || envelope.Error.Message != "business validation failed" {
t.Fatalf("failure envelope=%+v", envelope)
}
assertOutputFile(t, target, "original", 0o640)
assertNoOutputTemps(t, target)
}
func TestOutputSinkPanicCleansTempAndPreservesTarget(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
_, _ = fmt.Fprint(cmd.OutOrStdout(), "partial")
panic("boom")
})
root.SetArgs([]string{"atomic-output", "--output", target})
if recovered := executeAndRecover(root); recovered == nil {
t.Fatal("ExecuteC did not panic")
}
assertOutputFile(t, target, "original", 0o600)
assertNoOutputTemps(t, target)
}
func TestOutputSinkRenameFailurePreservesTarget(t *testing.T) {
testseam.Swap(t, &rootRenameFile, func(string, string) error {
return errors.New("rename failed")
})
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
_, err := fmt.Fprint(cmd.OutOrStdout(), "replacement")
return err
})
root.SetArgs([]string{"atomic-output", "--output", target})
if _, err := root.ExecuteC(); err == nil || err.Error() == "" {
t.Fatalf("ExecuteC error=%v, want publication failure", err)
}
assertOutputFile(t, target, "original", 0o600)
assertNoOutputTemps(t, target)
}
func TestOutputSinkSyncAndCloseFailuresPreserveTarget(t *testing.T) {
tests := []struct {
name string
seam func(*testing.T)
}{
{
name: "sync",
seam: func(t *testing.T) {
testseam.Swap(t, &rootSyncFile, func(*os.File) error { return errors.New("sync failed") })
},
},
{
name: "close",
seam: func(t *testing.T) {
testseam.Swap(t, &rootCloseFile, func(file *os.File) error {
_ = file.Close()
return errors.New("close failed")
})
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.seam(t)
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
_, err := fmt.Fprint(cmd.OutOrStdout(), "replacement")
return err
})
root.SetArgs([]string{"atomic-output", "--output", target})
if _, err := root.ExecuteC(); err == nil {
t.Fatal("ExecuteC succeeded")
}
assertOutputFile(t, target, "original", 0o600)
assertNoOutputTemps(t, target)
})
}
}
func TestOutputSinkUnifiedPublicationFailureFailsAndLeavesNoFinalFile(t *testing.T) {
testseam.Swap(t, &rootRenameFile, func(string, string) error {
return errors.New("rename failed")
})
dir := t.TempDir()
target := filepath.Join(dir, "result.json")
root := NewRootCommand()
leaf := &cobra.Command{
Use: "atomic-output-unified",
RunE: func(cmd *cobra.Command, _ []string) error {
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
root.SetArgs([]string{"atomic-output-unified", "--output", target})
if _, err := root.ExecuteC(); err == nil {
t.Fatal("unified ExecuteC succeeded without publishing its output")
} else if code := apperrors.ExitCode(err); code != 5 {
t.Fatalf("publication exit code=%d, want 5: %v", code, err)
}
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("final output exists after publication failure: %v", err)
}
assertNoOutputTemps(t, target)
}
func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
testseam.Protect(t, &os.Args)
dir := t.TempDir()
target := filepath.Join(dir, "result.json")
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
t.Fatal(err)
}
os.Args = []string{"dws", "atomic-output-unified-publication", "--output", target, "--format", "json"}
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
testseam.Swap(t, &rootStopAllStdioClients, func() {})
var stdout, stderr bytes.Buffer
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, engine *pipeline.Engine) *cobra.Command {
root := NewRootCommandWithEngine(ctx, engine)
root.SetOut(&stdout)
root.SetErr(&stderr)
leaf := &cobra.Command{
Use: "atomic-output-unified-publication",
RunE: func(cmd *cobra.Command, _ []string) error {
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
return root
})
if code := Execute(); code != 5 {
t.Fatalf("Execute exit code=%d, want publication failure code 5; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
var envelope output.Envelope
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("publication failure stdout=%q: %v; stderr=%q", stdout.String(), err, stderr.String())
}
if envelope.OK || envelope.Outcome != output.OutcomeFailure || envelope.Error == nil || envelope.Error.Type != "internal" || envelope.Error.ExitCode != 5 {
t.Fatalf("publication failure envelope=%+v", envelope)
}
if !strings.Contains(envelope.Error.Message, "failed to publish output file") {
t.Fatalf("publication failure message=%q", envelope.Error.Message)
}
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "failure"`)); got != 1 {
t.Fatalf("stdout contains %d failure envelopes, want one: %s", got, stdout.String())
}
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 0 {
t.Fatalf("rolled-back success leaked to stdout: %s", stdout.String())
}
assertOutputFile(t, target, "original", 0o640)
assertNoOutputTemps(t, target)
}
func TestOutputSinkEmissionFailurePreservesTarget(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "result.json")
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
root := NewRootCommand()
leaf := &cobra.Command{
Use: "atomic-emission-failure",
RunE: func(cmd *cobra.Command, _ []string) error {
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"})); err != nil {
return err
}
return cmd.OutOrStdout().(*os.File).Close()
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
root.SetArgs([]string{"atomic-emission-failure", "--output", target})
if _, err := root.ExecuteC(); err == nil {
t.Fatal("ExecuteC succeeded after emission failure")
}
assertOutputFile(t, target, "original", 0o600)
assertNoOutputTemps(t, target)
}
func TestOutputSinkValidationFailureDoesNotCreateTemp(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
root := NewRootCommand()
leaf := &cobra.Command{Use: "atomic-validation", RunE: func(*cobra.Command, []string) error { return nil }}
leaf.Flags().String("required", "", "")
_ = leaf.MarkFlagRequired("required")
root.AddCommand(leaf)
root.SetArgs([]string{"atomic-validation", "--output", target})
if _, err := root.ExecuteC(); err == nil {
t.Fatal("ExecuteC succeeded without required flag")
}
assertOutputFile(t, target, "original", 0o600)
assertNoOutputTemps(t, target)
}
func newAtomicOutputTestRoot(run func(*cobra.Command) error) *cobra.Command {
root := NewRootCommand()
root.AddCommand(&cobra.Command{
Use: "atomic-output",
RunE: func(cmd *cobra.Command, _ []string) error {
return run(cmd)
},
})
return root
}
func executeAndRecover(cmd *cobra.Command) (recovered any) {
defer func() { recovered = recover() }()
_, _ = cmd.ExecuteC()
return nil
}
func assertOutputFile(t *testing.T, path, want string, wantMode os.FileMode) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read output: %v", err)
}
if string(data) != want {
t.Fatalf("output=%q, want %q", data, want)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat output: %v", err)
}
if mode := info.Mode().Perm(); mode != wantMode {
t.Fatalf("output mode=%#o, want %#o", mode, wantMode)
}
}
func assertNoOutputTemps(t *testing.T, target string) {
t.Helper()
matches, err := filepath.Glob(filepath.Join(filepath.Dir(target), "."+filepath.Base(target)+".tmp-*"))
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("temporary output files remain: %v", matches)
}
}
@@ -0,0 +1,38 @@
package app
import (
"bytes"
"context"
"path/filepath"
"testing"
)
// TestChatDownloadMediaAliasPreRunNormalizesRequiredFlag is the root-level
// regression for the alias normalization order: root's persistent pre-run must
// not run Cobra's required-flag validation ahead of the leaf PreRunE.
// chat message download-media copies --msg-id / --open-message-id into the
// required --message-id flag in its PreRunE; validating early failed that
// documented alias path with "missing required flag(s): --message-id".
func TestChatDownloadMediaAliasPreRunNormalizesRequiredFlag(t *testing.T) {
for _, alias := range []string{"msg-id", "open-message-id"} {
t.Run(alias, func(t *testing.T) {
root := NewRootCommand(context.Background())
var stdout, stderr bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&stderr)
target := filepath.Join(t.TempDir(), "download.bin")
root.SetArgs([]string{
"chat", "message", "download-media",
"--type", "mediaId",
"--resource-id", "media-1",
"--" + alias, "msg-1",
"--open-conversation-id", "cid-1",
"--output", target,
"--dry-run", "--format", "json",
})
if _, err := root.ExecuteC(); err != nil {
t.Fatalf("ExecuteC with alias --%s: %v\nstdout: %s\nstderr: %s", alias, err, stdout.String(), stderr.String())
}
})
}
}
+229
View File
@@ -0,0 +1,229 @@
// 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"
"context"
"errors"
"os"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/spf13/cobra"
)
func TestExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
rootNormalizeProcessProfileArgs = oldNormalize
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootStopAllStdioClients = func() {}
rootNewRootCommandWithEngine = func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
cmd.SetContext(ctx)
return cmd
}
var stdout, stderr bytes.Buffer
executed := &cobra.Command{Use: "leaf"}
output.SetCommandRollout(executed, output.RolloutUnifiedActive)
executed.SetOut(&stdout)
executed.SetErr(&stderr)
rootExecuteCommand = func(root *cobra.Command) (*cobra.Command, error) {
executed.SetContext(root.Context())
if err := output.StoreResult(executed.Context(), output.Success(map[string]any{"id": "a"})); err != nil {
return executed, err
}
if _, _, err := output.EmitStoredResult(executed); err != nil {
return executed, err
}
return executed, nil
}
if code := Execute(); code != 0 {
t.Fatalf("Execute code=%d, want 0", code)
}
if stderr.Len() != 0 {
t.Fatalf("stderr=%q, want diagnostics only/empty", stderr.String())
}
if !strings.Contains(stdout.String(), `"outcome": "success"`) || strings.Contains(stdout.String(), `"contract_version"`) {
t.Fatalf("stdout does not match the unified envelope: %s", stdout.String())
}
}
// TestRootExecutionErrorToStderrOnly 是 B184 的回归断言:失败信封(JSON 错误
// 输出)恒走 stderr,stdout 严格为空(契约 §5.1:失败时 stdout 必须为空)。
// printExecutionError 把 PrintJSON/PrintHuman 都写 stderr writer,stdout
// writer 不得收到任何字节。
func TestRootExecutionErrorToStderrOnly(t *testing.T) {
t.Parallel()
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "json", "")
_ = root.PersistentFlags().Set("format", "json")
var stdout, stderr bytes.Buffer
if err := printExecutionError(root, &stdout, &stderr, apperrors.NewAuth("token expired")); err != nil {
t.Fatalf("printExecutionError() error = %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("failure must keep stdout empty, got %q", stdout.String())
}
want := "{\n \"error\": {\n \"category\": \"auth\",\n \"code\": 2,\n \"message\": \"token expired\"\n }\n}\n"
if got := stderr.String(); got != want {
t.Fatalf("legacy root error wire changed\n got: %q\nwant: %q", got, want)
}
}
// TestRootHumanErrorToStderrOnly 是 B184 的人类可读分支断言:非 JSON 模式下,
// 失败走 stderr(PrintHuman),stdout 为空。
func TestRootHumanErrorToStderrOnly(t *testing.T) {
t.Parallel()
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "table", "")
_ = root.PersistentFlags().Set("format", "table")
var stdout, stderr bytes.Buffer
if err := printExecutionError(root, &stdout, &stderr, apperrors.NewInternal("boom")); err != nil {
t.Fatalf("printExecutionError() error = %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("failure must keep stdout empty, got %q", stdout.String())
}
if !strings.Contains(stderr.String(), "Error:") {
t.Fatalf("expected human error on stderr, got %q", stderr.String())
}
}
// TestRootExecuteOutcomeToExitCode 是 B185 的 Execute 出口断言:Execute 把
// 命令返回的 error 类别映射为进程退出码(apperrors.ExitCode)。ok→0、
// confirmation/validation→3、panic 与 unrepresentable partial error→5。
func TestRootExecuteOutcomeToExitCode(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
rootNormalizeProcessProfileArgs = oldNormalize
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootStopAllStdioClients = func() {}
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
}
// ok / pending(信封 success/pending 语义)→ 0
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, nil }
if code := Execute(); code != 0 {
t.Fatalf("success Execute code = %d, want 0", code)
}
// An error cannot carry partial succeeded/failed data and fails closed.
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
return nil, &apperrors.Error{Category: apperrors.CategoryPartial, Message: "partial"}
}
if code := Execute(); code != 5 {
t.Fatalf("partial error Execute code = %d, want 5", code)
}
// confirmation_required(validation 子类)→ 3
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
return nil, apperrors.NewValidation("blocked", apperrors.WithReason("confirmation_required"))
}
if code := Execute(); code != 3 {
t.Fatalf("confirmation Execute code = %d, want 3", code)
}
// plain internal → 5
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
return nil, errors.New("plain")
}
if code := Execute(); code != 5 {
t.Fatalf("plain Execute code = %d, want 5", code)
}
}
// TestRootSilenceErrorsAndDeferTeardown 是 B186 的断言:根命令 SilenceErrors/
// SilenceUsage 打开(Cobra 不自行打印),且 Execute 出口 defer 收尾路径
// (StopAllStdioClients)在错误路径也被调用。
func TestRootSilenceErrorsAndDeferTeardown(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
rootNormalizeProcessProfileArgs = oldNormalize
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
stopped := false
rootStopAllStdioClients = func() { stopped = true }
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
}
// 错误路径:Execute 返回非零,且 defer 收尾(StopAllStdioClients)被调用。
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
return nil, apperrors.NewInternal("fail")
}
_ = Execute()
if !stopped {
t.Fatal("defer teardown (StopAllStdioClients) not called on error path")
}
}
// TestRootSilenceErrorsFlag 断言根命令的 SilenceErrors/SilenceUsage 为真,
// 保证 Cobra 不自行在错误时打印 usage/错误(错误渲染统一走 printExecutionError)。
func TestRootSilenceErrorsFlag(t *testing.T) {
t.Parallel()
root := NewRootCommand()
if !root.SilenceErrors || !root.SilenceUsage {
t.Fatalf("root must set SilenceErrors=%v SilenceUsage=%v", root.SilenceErrors, root.SilenceUsage)
}
}
+56
View File
@@ -25,11 +25,32 @@ func TestRuntimeSchemaCompletenessCoversPublicCommandTree(t *testing.T) {
if !containsSchemaPath(report.Covered, "chat category create-smart") {
t.Fatal("chat category create-smart is not covered by runtime Schema")
}
for _, path := range missingChatCatalogCoveragePaths() {
if !containsSchemaPath(report.Covered, path) {
t.Fatalf("%s is not covered by runtime Schema", path)
}
}
if !containsSchemaPath(report.Excluded, "agoal strategy list") {
t.Fatal("agoal strategy list is not recorded as a reviewed exclusion")
}
}
func TestRuntimeSchemaCompletenessDoesNotExcludeMissingChatCatalogPaths(t *testing.T) {
exclusions, err := cli.ReviewedRuntimeSchemaExclusions()
if err != nil {
t.Fatal(err)
}
excluded := map[string]bool{}
for _, exclusion := range exclusions {
excluded[exclusion.CLIPath] = true
}
for _, path := range missingChatCatalogCoveragePaths() {
if excluded[path] {
t.Fatalf("%s must not remain in runtime Schema exclusions", path)
}
}
}
func containsSchemaPath(paths []string, want string) bool {
for _, path := range paths {
if path == want {
@@ -38,3 +59,38 @@ func containsSchemaPath(paths []string, want string) bool {
}
return false
}
func missingChatCatalogCoveragePaths() []string {
return []string{
"chat category add-conv",
"chat category create",
"chat category delete",
"chat category remove-conv",
"chat category rename",
"chat chmod",
"chat clear-all-red-point",
"chat clear-messages",
"chat clear-red-point",
"chat data-auth cross-org",
"chat group audit-join-validation",
"chat group list-all",
"chat group list-join-validations",
"chat group members list-by-ids",
"chat group notice create",
"chat group notice edit",
"chat group notice get",
"chat group notice list",
"chat group share-invite",
"chat group update-alias",
"chat hide",
"chat list-all-conversations",
"chat mark-read",
"chat mark-unread",
"chat message list-emotion-replies",
"chat message set-top-msg",
"chat message unset-top-msg",
"chat mute-at-all",
"chat mute-red-envelope",
"chat text translate",
}
}
+136 -5
View File
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 357
publicShortcutCount = 378
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including hidden leaves such as minutes.shortcut_minutes_search.
schemaPublishedShortcutCount = 358
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 379
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 357
publiclyDeliveredShortcutCount = 378
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -114,12 +114,14 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 187; got != want {
if got, want := int(product["count"].(float64)), 217; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
shortcutCount := 0
summaryByCLIPath := make(map[string]map[string]any, len(summaries))
for _, summary := range summaries {
summaryByCLIPath[schemaContractString(summary["cli_path"])] = summary
if strings.HasPrefix(schemaContractString(summary["canonical_path"]), "chat.shortcut_") {
shortcutCount++
}
@@ -127,6 +129,135 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
if shortcutCount != 98 {
t.Fatalf("schema chat shortcut summaries = %d, want 98", shortcutCount)
}
for _, cliPath := range missingChatCatalogCoveragePaths() {
if summaryByCLIPath[cliPath] == nil {
t.Fatalf("schema chat missing expected catalog tool %q", cliPath)
}
}
assertSchemaSummarySafety(t, summaryByCLIPath, "chat clear-messages", "destructive", "high", "user_required")
assertSchemaSummarySafety(t, summaryByCLIPath, "chat data-auth cross-org", "write", "high", "user_required")
assertSchemaSummarySafety(t, summaryByCLIPath, "chat group share-invite", "write", "medium", "user_required")
assertChatCatalogCompleteLeafContracts(t)
}
func assertSchemaSummarySafety(
t testing.TB,
summaries map[string]map[string]any,
cliPath string,
effect string,
risk string,
confirmation string,
) {
t.Helper()
summary := summaries[cliPath]
if summary == nil {
t.Fatalf("schema chat missing expected catalog tool %q", cliPath)
}
if got := schemaContractString(summary["effect"]); got != effect {
t.Fatalf("%s effect = %q, want %q", cliPath, got, effect)
}
if got := schemaContractString(summary["risk"]); got != risk {
t.Fatalf("%s risk = %q, want %q", cliPath, got, risk)
}
if got := schemaContractString(summary["confirmation"]); got != confirmation {
t.Fatalf("%s confirmation = %q, want %q", cliPath, got, confirmation)
}
}
func assertChatCatalogCompleteLeafContracts(t testing.TB) {
t.Helper()
for _, cliPath := range []string{
"chat clear-messages",
"chat clear-red-point",
"chat hide",
"chat mark-read",
"chat mark-unread",
"chat mute-at-all",
"chat mute-red-envelope",
} {
leaf := executeShortcutSchemaQuery(t, "--cli-path", cliPath)
assertSchemaLeafParameterRequired(t, leaf, cliPath, "conversation-id", false)
assertSchemaLeafConstraints(t, leaf, cliPath, map[string]any{
"require_one_of": [][]string{{"conversation-id", "id", "chat"}},
"mutually_exclusive": [][]string{{"conversation-id", "id", "chat"}},
})
}
markRead := executeShortcutSchemaQuery(t, "--cli-path", "chat mark-read")
assertSchemaLeafParameterRequired(t, markRead, "chat mark-read", "message-id", true)
chmod := executeShortcutSchemaQuery(t, "--cli-path", "chat chmod")
assertSchemaLeafConstraints(t, chmod, "chat chmod", map[string]any{
"require_one_of": [][]string{{"conversation-id", "open-dingtalk-id", "user", "permParam"}},
"mutually_exclusive": [][]string{{"conversation-id", "open-dingtalk-id", "user"}},
})
assertChatGrantParameterFacts(t, chmod, "chat chmod")
crossOrg := executeShortcutSchemaQuery(t, "--cli-path", "chat data-auth cross-org")
assertSchemaLeafConstraints(t, crossOrg, "chat data-auth cross-org", map[string]any{
"require_one_of": [][]string{{"target-org-id", "all"}},
"mutually_exclusive": [][]string{{"target-org-id", "all"}},
})
assertChatGrantParameterFacts(t, crossOrg, "chat data-auth cross-org")
shareInvite := executeShortcutSchemaQuery(t, "--cli-path", "chat group share-invite")
assertSchemaLeafConstraints(t, shareInvite, "chat group share-invite", map[string]any{
"require_one_of": [][]string{{"target", "receiver"}},
"mutually_exclusive": [][]string{{"target", "receiver"}},
})
auditJoin := executeShortcutSchemaQuery(t, "--cli-path", "chat group audit-join-validation")
assertSchemaLeafParameterEnum(t, auditJoin, "chat group audit-join-validation", "status", []string{"AuditApprove", "AuditDelete"})
}
func assertSchemaLeafParameterRequired(t testing.TB, leaf map[string]any, cliPath, name string, want bool) {
t.Helper()
parameters := schemaContractMap(leaf["parameters"])
parameter := parameters[name]
if parameter == nil {
t.Fatalf("%s missing --%s parameter: %#v", cliPath, name, parameters)
}
if got, _ := parameter["required"].(bool); got != want {
t.Fatalf("%s --%s required = %#v, want %v", cliPath, name, parameter["required"], want)
}
}
func assertSchemaLeafParameterEnum(t testing.TB, leaf map[string]any, cliPath, name string, want []string) {
t.Helper()
parameters := schemaContractMap(leaf["parameters"])
parameter := parameters[name]
if parameter == nil {
t.Fatalf("%s missing --%s parameter: %#v", cliPath, name, parameters)
}
if got := schemaContractStringSlice(parameter["enum"]); !schemaContractJSONEqual(got, want) {
t.Fatalf("%s --%s enum = %#v, want %#v", cliPath, name, got, want)
}
}
func assertSchemaLeafConstraints(t testing.TB, leaf map[string]any, cliPath string, want map[string]any) {
t.Helper()
if got := leaf["constraints"]; !schemaContractJSONEqual(got, want) {
t.Fatalf("%s constraints = %#v, want %#v", cliPath, got, want)
}
}
func assertChatGrantParameterFacts(t testing.TB, leaf map[string]any, cliPath string) {
t.Helper()
parameters := schemaContractMap(leaf["parameters"])
grantType := parameters["grant-type"]
if grantType == nil {
t.Fatalf("%s missing --grant-type parameter: %#v", cliPath, parameters)
}
wantEnum := []string{"once", "session", "timed", "permanent"}
if got := schemaContractStringSlice(grantType["enum"]); !schemaContractJSONEqual(got, wantEnum) {
t.Fatalf("%s --grant-type enum = %#v, want %#v", cliPath, got, wantEnum)
}
if got := schemaContractString(parameters["session-id"]["required_when"]); got != "grant-type is session" {
t.Fatalf("%s --session-id required_when = %q, want grant-type is session", cliPath, got)
}
if got := schemaContractString(parameters["ttl"]["required_when"]); got != "grant-type is timed" {
t.Fatalf("%s --ttl required_when = %q, want grant-type is timed", cliPath, got)
}
}
func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testing.T) {
+137
View File
@@ -0,0 +1,137 @@
package app
import (
"context"
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
var rootEscalateSignal = func(sig os.Signal) {
signal.Reset(sig)
redeliverProcessSignal(sig)
}
var (
rootFindProcess = os.FindProcess
rootExitProcess = os.Exit
)
// redeliverProcessSignal asks the current process to handle the second signal
// with the platform's default semantics. Platforms that cannot deliver the
// requested signal through os.Process.Signal fall back to the conventional
// CLI exit status instead of leaving the process running after escalation.
func redeliverProcessSignal(sig os.Signal) {
process, err := rootFindProcess(os.Getpid())
if err == nil {
err = process.Signal(sig)
}
if err != nil {
rootExitProcess(interruptionExitCode(sig))
}
}
func interruptionExitCode(sig os.Signal) int {
if sig == syscall.SIGTERM {
return 143
}
return 130
}
type processInterruption struct {
signal os.Signal
}
func (e *processInterruption) Error() string {
return fmt.Sprintf("process interrupted by %s", e.signal)
}
func (e *processInterruption) Unwrap() error { return context.Canceled }
func (e *processInterruption) ExitCode() int {
return interruptionExitCode(e.signal)
}
func (e *processInterruption) Subtype() string {
if e.signal == syscall.SIGTERM {
return "terminated"
}
return "cancelled_by_user"
}
type processSignalState struct {
mu sync.Mutex
interruption *processInterruption
primaryCompletedAtSignal bool
}
func (s *processSignalState) record(sig os.Signal, store *output.ResultStore) (first bool) {
s.mu.Lock()
defer s.mu.Unlock()
if s.interruption != nil {
return false
}
_, _, s.primaryCompletedAtSignal, _ = output.StoredEmissionState(store)
s.interruption = &processInterruption{signal: sig}
return true
}
func (s *processSignalState) outcome() (*processInterruption, bool) {
s.mu.Lock()
defer s.mu.Unlock()
return s.interruption, s.primaryCompletedAtSignal
}
func installProcessSignalContext(parent context.Context, store *output.ResultStore) (context.Context, *processSignalState, func()) {
signals := make(chan os.Signal, 2)
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
return manageProcessSignals(parent, store, signals, func() { signal.Stop(signals) }, rootEscalateSignal)
}
func manageProcessSignals(
parent context.Context,
store *output.ResultStore,
signals <-chan os.Signal,
stopNotify func(),
escalate func(os.Signal),
) (context.Context, *processSignalState, func()) {
ctx, cancel := context.WithCancelCause(parent)
state := &processSignalState{}
done := make(chan struct{})
stopped := make(chan struct{})
var stopOnce sync.Once
go func() {
defer close(stopped)
for {
select {
case sig := <-signals:
if sig == nil {
continue
}
if state.record(sig, store) {
cancel(state.interruption)
continue
}
escalate(sig)
return
case <-done:
return
}
}
}()
stop := func() {
stopOnce.Do(func() {
stopNotify()
close(done)
<-stopped
cancel(context.Canceled)
})
}
return ctx, state, stop
}
+336
View File
@@ -0,0 +1,336 @@
package app
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"syscall"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func signalSelf(t *testing.T, sig syscall.Signal) {
t.Helper()
process, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatalf("find current process: %v", err)
}
if err := process.Signal(sig); err != nil {
t.Skipf("current platform does not support process signal delivery: %v", err)
}
}
func TestFrameworkSignalRedeliveryFallbackAndInterruptionMethods(t *testing.T) {
originalFind, originalExit := rootFindProcess, rootExitProcess
t.Cleanup(func() { rootFindProcess, rootExitProcess = originalFind, originalExit })
rootFindProcess = func(int) (*os.Process, error) { return nil, errors.New("find failed") }
exitCode := 0
rootExitProcess = func(code int) { exitCode = code }
rootEscalateSignal(syscall.SIGTERM)
if exitCode != 143 {
t.Fatalf("escalation exit=%d", exitCode)
}
exitCode = 0
redeliverProcessSignal(syscall.SIGTERM)
if exitCode != 143 {
t.Fatalf("fallback exit=%d", exitCode)
}
rootFindProcess = func(int) (*os.Process, error) { return os.FindProcess(99999999) }
exitCode = 0
redeliverProcessSignal(syscall.SIGINT)
if exitCode != 130 {
t.Fatalf("signal fallback exit=%d", exitCode)
}
interrupted := &processInterruption{signal: syscall.SIGINT}
if !errors.Is(interrupted, context.Canceled) || interrupted.ExitCode() != 130 || interrupted.Subtype() != "cancelled_by_user" || !strings.Contains(interrupted.Error(), "interrupt") {
t.Fatalf("interruption=%v", interrupted)
}
terminated := &processInterruption{signal: syscall.SIGTERM}
if terminated.ExitCode() != 143 || terminated.Subtype() != "terminated" {
t.Fatalf("termination=%v", terminated)
}
state := &processSignalState{}
if !state.record(syscall.SIGINT, nil) || state.record(syscall.SIGTERM, nil) {
t.Fatal("signal state did not reject a second interruption")
}
}
func TestFrameworkManageProcessSignalsNilAndEscalation(t *testing.T) {
signals := make(chan os.Signal, 3)
stopped, escalated := false, make(chan os.Signal, 1)
ctx, _, stop := manageProcessSignals(context.Background(), nil, signals, func() { stopped = true }, func(sig os.Signal) { escalated <- sig })
signals <- nil
signals <- syscall.SIGINT
<-ctx.Done()
signals <- syscall.SIGTERM
if got := <-escalated; got != syscall.SIGTERM {
t.Fatalf("escalated=%v", got)
}
stop()
stop()
if !stopped {
t.Fatal("signal notification was not stopped")
}
}
func installSignalExecuteSeams(t *testing.T, unified bool, stdout, stderr io.Writer) {
t.Helper()
testseam.Protect(t, &os.Args)
os.Args = []string{"dws"}
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
testseam.Swap(t, &rootStopAllStdioClients, func() {})
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
if unified {
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
}
cmd.SetContext(ctx)
cmd.SetOut(stdout)
cmd.SetErr(stderr)
return cmd
})
}
func TestExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
for _, tc := range []struct {
name string
signal syscall.Signal
code int
subtype string
}{
{name: "SIGINT", signal: syscall.SIGINT, code: 130, subtype: "cancelled_by_user"},
{name: "SIGTERM", signal: syscall.SIGTERM, code: 143, subtype: "terminated"},
} {
t.Run(tc.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, &stderr)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
signalSelf(t, tc.signal)
<-cmd.Context().Done()
return cmd, cmd.Context().Err()
})
if code := Execute(); code != tc.code {
t.Fatalf("Execute code=%d, want %d", code, tc.code)
}
var env output.Envelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode envelope: %v; output=%q", err, stdout.String())
}
if env.Error == nil || env.Error.Type != "internal" || env.Error.Subtype != tc.subtype || env.Error.ExitCode != tc.code {
t.Fatalf("error=%+v, want internal/%s exit %d", env.Error, tc.subtype, tc.code)
}
if bytes.Count(stdout.Bytes(), []byte(`"outcome": "failure"`)) != 1 {
t.Fatalf("stdout must contain one failure envelope: %s", stdout.String())
}
})
}
}
func TestExecuteSignalLegacyExitCodes(t *testing.T) {
for _, tc := range []struct {
signal syscall.Signal
code int
}{{syscall.SIGINT, 130}, {syscall.SIGTERM, 143}} {
t.Run(tc.signal.String(), func(t *testing.T) {
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
signalSelf(t, tc.signal)
<-cmd.Context().Done()
return cmd, cmd.Context().Err()
})
if code := Execute(); code != tc.code {
t.Fatalf("Execute code=%d, want %d", code, tc.code)
}
})
}
}
func TestExecuteDeadlineIsNotSignalCancellation(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
return cmd, context.DeadlineExceeded
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d, want internal deadline code 5", code)
}
var env output.Envelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatal(err)
}
if env.Error == nil || env.Error.Subtype != "deadline_exceeded" || env.Error.ExitCode == 130 || env.Error.ExitCode == 143 {
t.Fatalf("deadline error=%+v", env.Error)
}
}
func TestSignalAfterFailedEmissionAttemptPreservesPublicationExitCode(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
cmd.SetOut(failingWriter{})
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
_, _, _ = output.EmitStoredResult(cmd)
signalSelf(t, syscall.SIGINT)
<-cmd.Context().Done()
return cmd, cmd.Context().Err()
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d, want publication failure code 5", code)
}
if stdout.Len() != 0 {
t.Fatalf("second envelope emitted: %q", stdout.String())
}
}
func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
// Record cancellation before publication begins, then simulate a command
// hook that has already committed its result and completes publication.
// The wire result must remain authoritative over the earlier signal.
signalSelf(t, syscall.SIGINT)
<-cmd.Context().Done()
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
if _, _, err := output.EmitStoredResult(cmd); err != nil {
t.Fatal(err)
}
return cmd, cmd.Context().Err()
})
if code := Execute(); code != 0 {
t.Fatalf("Execute code=%d, want published success code 0", code)
}
var env output.Envelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode envelope: %v; output=%q", err, stdout.String())
}
if !env.OK || env.Outcome != output.OutcomeSuccess {
t.Fatalf("published envelope=%+v, want successful outcome", env)
}
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 1 {
t.Fatalf("stdout contains %d success envelopes, want one: %s", got, stdout.String())
}
}
func TestSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
if _, _, err := output.EmitStoredResult(cmd); err != nil {
t.Fatal(err)
}
signalSelf(t, syscall.SIGINT)
<-cmd.Context().Done()
return cmd, cmd.Context().Err()
})
if code := Execute(); code != 0 {
t.Fatalf("Execute code=%d, want established success code 0", code)
}
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 1 {
t.Fatalf("stdout contains %d success envelopes, want one: %s", got, stdout.String())
}
}
func TestExecuteSignalSubprocessExitStatus(t *testing.T) {
if os.Getenv("DWS_SIGNAL_HELPER") == "1" {
installSignalExecuteSeams(t, true, os.Stdout, os.Stderr)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
_, _ = fmt.Fprintln(os.Stderr, "READY")
<-cmd.Context().Done()
return cmd, cmd.Context().Err()
})
os.Exit(Execute())
}
for _, tc := range []struct {
name string
signal syscall.Signal
code int
subtype string
}{
{name: "SIGINT", signal: syscall.SIGINT, code: 130, subtype: "cancelled_by_user"},
{name: "SIGTERM", signal: syscall.SIGTERM, code: 143, subtype: "terminated"},
} {
t.Run(tc.name, func(t *testing.T) {
cmd := exec.Command(os.Args[0], "-test.run=^TestExecuteSignalSubprocessExitStatus$")
cmd.Env = append(os.Environ(), "DWS_SIGNAL_HELPER=1")
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
if scanner := bufio.NewScanner(stderr); !scanner.Scan() || scanner.Text() != "READY" {
t.Fatalf("helper readiness failed: %q, err=%v", scanner.Text(), scanner.Err())
}
if err := cmd.Process.Signal(tc.signal); err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
t.Skipf("current platform does not support subprocess signal delivery: %v", err)
}
payload, readErr := io.ReadAll(stdout)
if readErr != nil {
t.Fatal(readErr)
}
waitErr := cmd.Wait()
var exitErr *exec.ExitError
if !errors.As(waitErr, &exitErr) || exitErr.ExitCode() != tc.code {
t.Fatalf("wait error=%v, want exit %d", waitErr, tc.code)
}
var env output.Envelope
if err := json.Unmarshal(payload, &env); err != nil {
t.Fatalf("decode helper output: %v; output=%q", err, payload)
}
if env.Error == nil || env.Error.Subtype != tc.subtype || env.Error.ExitCode != tc.code {
t.Fatalf("helper error=%+v", env.Error)
}
})
}
}
func TestSecondSignalUsesEscalationSeam(t *testing.T) {
signals := make(chan os.Signal, 2)
escalated := make(chan os.Signal, 1)
ctx, _, stop := manageProcessSignals(context.Background(), nil, signals, func() {}, func(sig os.Signal) {
escalated <- sig
})
signals <- syscall.SIGINT
<-ctx.Done()
if !errors.Is(context.Cause(ctx), context.Canceled) {
t.Fatalf("cause=%v, want cancellation", context.Cause(ctx))
}
signals <- syscall.SIGTERM
if got := <-escalated; got != syscall.SIGTERM {
t.Fatalf("escalated %v, want SIGTERM", got)
}
stop()
}
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
+4
View File
@@ -44,6 +44,10 @@ import (
// test binary never launches a page on the developer's machine; tests that
// need to assert the URL can still replace openBrowserFunc locally.
func TestMain(m *testing.M) {
if code, ok := runRuntimeTokenDetachedE2EChild(); ok {
os.Exit(code)
}
tmpDir, err := os.MkdirTemp("", "dws-app-test-keychain-")
if err != nil {
panic("create test keychain tempdir: " + err.Error())
+40
View File
@@ -0,0 +1,40 @@
// 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.
package auth
import "testing"
func TestCrossPlatformCoverageResolveProfileMetadataUsesSelectorGrammarAndReturnsCopy(t *testing.T) {
cfg := &ProfilesConfig{
Version: 3,
Profiles: []Profile{
{Name: "Historical", CorpID: "corp-1", CorpName: "Example Org"},
{Name: "Exact", CorpID: "corp-1", CorpName: "Example Org", UserID: "user-1", UserName: "Example User", ClientID: "client-1"},
},
OrgCurrentProfiles: map[string]string{"corp-1": "corp-1:user-1"},
}
for _, selector := range []string{"corp-1", "Example Org", "corp-1:Example User", "Example Org:Example User"} {
profile, err := ResolveProfileMetadata(cfg, selector)
if err != nil {
t.Fatalf("ResolveProfileMetadata(%q) error = %v", selector, err)
}
if profile == nil || profile.UserID != "user-1" || profile.ClientID != "client-1" {
t.Fatalf("ResolveProfileMetadata(%q) = %#v", selector, profile)
}
}
profile, err := ResolveProfileMetadata(cfg, "corp-1:user-1")
if err != nil {
t.Fatalf("ResolveProfileMetadata(exact) error = %v", err)
}
profile.Name = "mutated copy"
if cfg.Profiles[1].Name != "Exact" {
t.Fatalf("ResolveProfileMetadata returned registry-owned pointer")
}
if _, err := ResolveProfileMetadata(cfg, "missing"); err == nil {
t.Fatal("ResolveProfileMetadata(missing) unexpectedly succeeded")
}
}
+14
View File
@@ -738,6 +738,20 @@ func ResolveProfileWithScope(configDir, selector string) (*Profile, bool, error)
return result, exact, err
}
// ResolveProfileMetadata applies the public profile-selector grammar to an
// already-loaded, non-sensitive profiles registry. It performs no migration,
// keychain access, token loading, or persistence, making it suitable for
// callers that carry an externally managed bearer credential and need only
// corp/user/client identity metadata.
func ResolveProfileMetadata(cfg *ProfilesConfig, selector string) (*Profile, error) {
profile, _, err := resolveProfileSelection("", cfg, selector)
if err != nil || profile == nil {
return nil, err
}
copy := *profile
return &copy, nil
}
func resolveProfileWithScopeLocked(configDir, selector string) (*Profile, bool, error) {
if err := profilesEnsureMigration(configDir); err != nil {
return nil, false, err
+2 -2
View File
@@ -71,7 +71,7 @@ func NewSchemaCommand() *cobra.Command {
Short: "渐进查看命令 Schema (产品 / 分组 / 工具参数)",
Long: `查看当前可运行命令的 Schema 元数据。
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束和安全语义。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束、安全语义和已评审的返回契约。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -113,7 +113,7 @@ func NewSchemaCommand() *cobra.Command {
},
}
cmd.Flags().Bool("all", false, "输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)")
cmd.Flags().Bool("compact", false, "按稳定字段白名单输出 Agent 选参、约束和安全语义")
cmd.Flags().Bool("compact", false, "按稳定字段白名单输出 Agent 选参、约束、安全语义和返回契约")
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询")
return cmd
}
@@ -14,6 +14,7 @@
package cli
import (
"encoding/json"
"errors"
"strings"
"testing"
@@ -21,11 +22,13 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *testing.T) {
cmd := &cobra.Command{Use: "create", Short: "s", Long: "l"}
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
cmd.Flags().String("mode", "", "usage")
runtimeannotate.AnnotateRuntimeFlag(cmd, "mode", "mode", "string", false)
@@ -35,6 +38,10 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
Effect: "write", Confirmation: "user_required", Idempotency: "none",
},
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewInvocation},
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string","description":"Created object ID"}}}`),
},
Selection: &contract.SelectionSpec{
AgentSummary: "from contract",
UseWhen: []string{"create things"},
@@ -68,6 +75,9 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
if spec.DryRun == nil || spec.DryRun.PreviewKind != contract.DryRunPreviewInvocation {
t.Fatalf("dry_run = %#v", spec.DryRun)
}
if spec.Result == nil || string(spec.Result.DataSchema) != `{"properties":{"id":{"type":"string","description":"Created object ID"}},"type":"object"}` {
t.Fatalf("result = %#v", spec.Result)
}
if spec.Selection.AgentSummary != "from contract" {
t.Fatalf("selection = %#v", spec.Selection)
}
@@ -79,6 +89,39 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
}
}
func TestRuntimeToolSpecHidesUnifiedResultForInactiveRollout(t *testing.T) {
for _, state := range []output.RolloutState{output.RolloutLegacyOnly, output.RolloutDualValidate} {
t.Run(string(state), func(t *testing.T) {
cmd := &cobra.Command{Use: "list"}
output.SetCommandRollout(cmd, state)
cmd.Flags().String("cursor", "", "cursor")
runtimeannotate.AnnotateRuntimeFlag(cmd, "cursor", "cursor", "string", false)
final := contract.ContractFinalPayload{
Identity: &contract.ToolIdentitySpec{
ProductID: "dev", Name: "list_things", CanonicalPath: "dev.list_things",
CLIPath: "dev list", PrimaryCLIPath: "dev list",
},
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
DataSchema: json.RawMessage(`{"type":"object"}`),
},
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
}
entry := runtimeSchemaEntry{
ProductID: "dev", ToolName: "list_things", CLIName: "list",
CLIPath: "dev list", PrimaryCLIPath: "dev list", ProductName: "Dev", Command: cmd,
}
spec, err := runtimeToolSpecFromContractFinal(entry, final, runtimeSchemaMetadataSources{})
if err != nil {
t.Fatal(err)
}
if spec.Result != nil || spec.Pagination != nil {
t.Fatalf("inactive rollout published result=%#v pagination=%#v", spec.Result, spec.Pagination)
}
})
}
}
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalIdentityMismatchFails(t *testing.T) {
entry := runtimeSchemaEntry{
ProductID: "dev",
+3 -2
View File
@@ -1059,6 +1059,7 @@ var schemaCompactPayloadKeys = map[string]bool{
"effect": true, "risk": true, "confirmation": true, "idempotency": true,
"interface_mode": true, "availability": true, "interface_reason": true,
"parameters": true, "constraints": true, "positionals": true, "dry_run": true,
"result": true, "pagination": true,
"examples": true, "use_when": true, "avoid_when": true,
}
@@ -1074,8 +1075,8 @@ var schemaCompactParamKeys = map[string]bool{
// stripSchemaPayloadCompact projects a full Schema payload onto the reviewed
// Agent-view allowlist. Structural product/tool children are projected
// recursively; constraint, positional and dry-run values are already typed
// contract data and are retained verbatim.
// recursively; result, constraint, positional and dry-run values are already
// typed contract data and are retained verbatim.
func stripSchemaPayloadCompact(payload map[string]any) map[string]any {
if payload == nil {
return nil
+33
View File
@@ -78,7 +78,9 @@ var schemaCatalogToolOptionalKeys = []string{
"interface_reason",
"interface_ref",
"metadata_source",
"pagination",
"positionals",
"result",
}
var schemaCatalogToolEnums = map[string][]string{
@@ -237,6 +239,37 @@ func validateCatalogToolEntry(toolID string, entry map[string]any, violations *[
}
validateCatalogInterface(toolID, entry, violations)
if result, exists := entry["result"]; exists {
if _, ok := result.(map[string]any); !ok {
report("field %q must be an object", "result")
}
}
if rawPagination, exists := entry["pagination"]; exists {
pagination, ok := rawPagination.(map[string]any)
if !ok {
report("field %q must be an object", "pagination")
} else {
want := map[string]string{
"kind": contract.PaginationKindCursor,
"meta_path": contract.PaginationMetaPath,
"endpoint_exhausted_path": contract.PaginationExhaustedPath,
"next_token_path": contract.PaginationNextTokenPath,
}
for field, expected := range want {
if value, _ := pagination[field].(string); value != expected {
report("field %q.%s = %q, want %q", "pagination", field, value, expected)
}
}
cursor, _ := pagination["cursor_parameter"].(string)
if strings.TrimSpace(cursor) == "" {
report("field %q.cursor_parameter must be a non-empty string", "pagination")
} else if paramsOK {
if _, exists := parameters[cursor]; !exists {
report("field %q.cursor_parameter references missing parameter %q", "pagination", cursor)
}
}
}
}
for paramName, raw := range parameters {
param, ok := raw.(map[string]any)
if !ok {
@@ -18,6 +18,8 @@ import (
"fmt"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// TestDeliverySchemaCatalogStructure gates the delivered catalog: every tool
@@ -100,6 +102,90 @@ func TestValidateCatalogStructureAcceptsValidEntry(t *testing.T) {
}
}
func TestValidateCatalogStructureAcceptsOptionalResultObject(t *testing.T) {
entry := validCatalogToolEntry()
entry["result"] = map[string]any{
"outcomes": []any{"success", "failure"},
"data_schema": map[string]any{"type": "object"},
}
if err := ValidateCatalogStructure(catalogPayload(t, entry)); err != nil {
t.Fatalf("ValidateCatalogStructure() error = %v", err)
}
entry["result"] = "invalid"
if err := ValidateCatalogStructure(catalogPayload(t, entry)); err == nil || !strings.Contains(err.Error(), `field "result" must be an object`) {
t.Fatalf("invalid result error = %v", err)
}
}
func TestValidateCatalogStructureAcceptsStandalonePagination(t *testing.T) {
entry := validCatalogToolEntry()
parameters := entry["parameters"].(map[string]any)
parameters["cursor"] = map[string]any{
"description": "续页游标",
"field_provenance": map[string]any{},
"required": false,
"type": "string",
}
entry["parameter_count"] = float64(len(parameters))
entry["has_parameters"] = true
entry["pagination"] = map[string]any{
"kind": contract.PaginationKindCursor,
"cursor_parameter": "cursor",
"meta_path": contract.PaginationMetaPath,
"endpoint_exhausted_path": contract.PaginationExhaustedPath,
"next_token_path": contract.PaginationNextTokenPath,
}
if err := ValidateCatalogStructure(catalogPayload(t, entry)); err != nil {
t.Fatalf("ValidateCatalogStructure() error = %v", err)
}
entry["pagination"].(map[string]any)["next_token_path"] = "data.nextCursor"
if err := ValidateCatalogStructure(catalogPayload(t, entry)); err == nil || !strings.Contains(err.Error(), "next_token_path") {
t.Fatalf("invalid pagination error = %v", err)
}
}
func TestValidateCatalogStructureRejectsMalformedStandalonePagination(t *testing.T) {
validPaginationEntry := func() map[string]any {
entry := validCatalogToolEntry()
parameters := entry["parameters"].(map[string]any)
parameters["cursor"] = map[string]any{
"description": "续页游标",
"field_provenance": map[string]any{},
"required": false,
"type": "string",
}
entry["parameter_count"] = float64(len(parameters))
entry["pagination"] = map[string]any{
"kind": contract.PaginationKindCursor,
"cursor_parameter": "cursor",
"meta_path": contract.PaginationMetaPath,
"endpoint_exhausted_path": contract.PaginationExhaustedPath,
"next_token_path": contract.PaginationNextTokenPath,
}
return entry
}
for _, tc := range []struct {
name string
mutate func(map[string]any)
want string
}{
{"not an object", func(entry map[string]any) { entry["pagination"] = "cursor" }, `field "pagination" must be an object`},
{"empty cursor", func(entry map[string]any) { entry["pagination"].(map[string]any)["cursor_parameter"] = " " }, "cursor_parameter must be a non-empty string"},
{"unknown cursor", func(entry map[string]any) { entry["pagination"].(map[string]any)["cursor_parameter"] = "page-token" }, "references missing parameter"},
} {
t.Run(tc.name, func(t *testing.T) {
entry := validPaginationEntry()
tc.mutate(entry)
err := ValidateCatalogStructure(catalogPayload(t, entry))
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("ValidateCatalogStructure() error = %v, want %q", err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageValidateCatalogStructureRejectsViolations(t *testing.T) {
cases := []struct {
name string
-33
View File
@@ -33,8 +33,6 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
"auth status",
"completion",
"config list",
"dev connect list",
"dev connect restart",
"doctor",
"plugin build",
"plugin config get",
@@ -93,40 +91,9 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
"calendar acl add",
"calendar acl delete",
"calendar book update",
"chat category add-conv",
"chat category create",
"chat category delete",
"chat category remove-conv",
"chat category rename",
"chat chmod",
"chat clear-all-red-point",
"chat clear-messages",
"chat clear-red-point",
"chat data-auth cross-org",
"chat group audit-join-validation",
"chat group list-all",
"chat group list-join-validations",
"chat group members list-by-ids",
"chat group notice create",
"chat group notice edit",
"chat group notice get",
"chat group notice list",
"chat group share-invite",
"chat group update-alias",
"chat hide",
"chat list-all-conversations",
"chat mark-read",
"chat mark-unread",
"chat message list-emotion-replies",
"chat message set-top-msg",
"chat message unset-top-msg",
"chat mute-at-all",
"chat mute-red-envelope",
"chat text translate",
"contact label get",
"contact label list",
"contact label list-members",
"dev app version check-approval",
"ding message list",
"ding message recall-personal",
"ding message receiver-status",
@@ -38,20 +38,33 @@ func init() {
registerRequireTogether("calendar.update_calendar_event", "recurrence-type", "recurrence-interval", "recurrence-range-type")
registerExclusiveOneOf("chat.search_messages_by_sender", "sender-user-id", "sender-open-dingtalk-id")
registerExclusiveOneOf("chat.create_and_send_card", "group", "receiver")
RegisterRuntimeSchemaConstraints("chat.chat_permission_grant", RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"conversation-id", "open-dingtalk-id", "user"}},
RequireOneOf: [][]string{{"conversation-id", "open-dingtalk-id", "user", "permParam"}},
})
registerExclusiveOneOf("chat.chat_permission_grant_cross_org_data", "target-org-id", "all")
registerRequireOneOf("chat.add_emoji_reaction", "conversation-id", "group", "id", "chat")
registerRequireOneOf("chat.add_text_emotion", "conversation-id", "group", "id", "chat")
registerExclusiveOneOf("chat.clear_conversation_messages", "conversation-id", "id", "chat")
registerExclusiveOneOf("chat.clear_conversation_red_point", "conversation-id", "id", "chat")
registerRequireOneOf("chat.update_text_emotion", "conversation-id", "group", "id", "chat")
registerExclusiveOneOf("chat.get_conversation_info", "group", "user", "open-dingtalk-id")
registerExclusiveOneOf("chat.hide_conversation", "conversation-id", "id", "chat")
registerExclusiveOneOf("chat.list_conversation_message_v2", "group", "user", "open-dingtalk-id")
registerExclusiveOneOf("chat.list_individual_chat_message", "user", "open-dingtalk-id")
registerExclusiveOneOf("chat.mark_conversation_unread", "conversation-id", "id", "chat")
registerExclusiveOneOf("chat.mark_message_read", "conversation-id", "id", "chat")
registerRequireOneOf("chat.remove_emoji_reaction", "conversation-id", "group", "id", "chat")
registerRequireOneOf("chat.remove_text_emotion", "conversation-id", "group", "id", "chat")
registerRequireOneOf("chat.send_personal_message", "text", "content", "msg-type")
registerExclusiveOneOf("chat.send_robot_message", "group", "users")
registerRequireOneOf("chat.set_group_member_mute_list", "users", "user")
registerExclusiveOneOf("chat.share_group_invite_url", "target", "receiver")
registerExclusiveOneOf("chat.transfer_group_owner", "new-owner", "user")
registerRequireOneOf("chat.update_conv_member_roles", "users", "user")
registerExclusiveOneOf("chat.update_at_all_notification_off", "conversation-id", "id", "chat")
registerRequireOneOf("chat.update_notification_off", "conversation-id", "id", "chat")
registerExclusiveOneOf("chat.update_red_env_notification_off", "conversation-id", "id", "chat")
registerRequireTogether("contact.query_dismission_employee_list", "start", "end")
registerRequireOneOf("dev.connect_status", "robot-client-id", "unified-app-id")
registerRequireOneOf("dev.connect_stop", "robot-client-id", "unified-app-id")
+39 -1
View File
@@ -60,6 +60,8 @@ type ToolSpec struct {
Constraints RuntimeSchemaConstraints
Positionals []contract.RuntimeSchemaPositional
DryRun *contract.DryRunSpec
Result *contract.ResultSpec
Pagination *contract.PaginationSpec
Safety contract.SafetySpec
Interface contract.InterfaceSpec
Selection contract.SelectionSpec
@@ -133,6 +135,8 @@ type RuntimeToolSpecInput struct {
Constraints RuntimeSchemaConstraints
Positionals []contract.RuntimeSchemaPositional
DryRun *contract.DryRunSpec
Result *contract.ResultSpec
Pagination *contract.PaginationSpec
Safety contract.SafetySpec
Interface contract.InterfaceSpec
Selection contract.SelectionSpec
@@ -538,6 +542,20 @@ func (t ToolSpec) Validate() error {
return err
}
}
if t.Result != nil {
if _, err := contract.NormalizeResultSpec(t.Result, id.CanonicalPath); err != nil {
return err
}
}
if t.Pagination != nil {
pagination, err := contract.NormalizePaginationSpec(t.Pagination, id.CanonicalPath)
if err != nil {
return err
}
if !seen[pagination.CursorParameter] {
return fmt.Errorf("tool %s pagination cursor_parameter %q is not a declared parameter", id.CanonicalPath, pagination.CursorParameter)
}
}
if t.Interface.Mode != "" || t.Interface.Availability != "" || t.Interface.Reason != "" || t.Interface.Ref != nil {
if err := t.Interface.Validate(id.CanonicalPath); err != nil {
return err
@@ -726,6 +744,18 @@ func (t ToolSpec) normalized() ToolSpec {
dryRun.PreviewKind = strings.TrimSpace(dryRun.PreviewKind)
out.DryRun = &dryRun
}
if t.Result != nil {
result, err := contract.NormalizeResultSpec(t.Result, id.CanonicalPath)
if err == nil {
out.Result = result
}
}
if t.Pagination != nil {
pagination, err := contract.NormalizePaginationSpec(t.Pagination, id.CanonicalPath)
if err == nil {
out.Pagination = pagination
}
}
out.Positionals = append([]contract.RuntimeSchemaPositional(nil), t.Positionals...)
sort.Slice(out.Positionals, func(i, j int) bool {
if out.Positionals[i].Index != out.Positionals[j].Index {
@@ -952,6 +982,14 @@ func (t ToolSpec) ToPayload() (map[string]any, error) {
value, _ := typedJSONValue(t.DryRun)
payload["dry_run"] = value
}
if t.Result != nil {
value, _ := typedJSONValue(t.Result)
payload["result"] = value
}
if t.Pagination != nil {
value, _ := typedJSONValue(t.Pagination)
payload["pagination"] = value
}
applySafetyPayload(payload, t.Safety)
applyInterfacePayload(payload, t.Interface)
applySelectionPayload(payload, t.Selection, true)
@@ -975,7 +1013,7 @@ func (t ToolSpec) ToSummaryPayload() (map[string]any, error) {
}
for _, key := range []string{
"parameters", "has_parameters", "parameter_count", "constraints",
"positionals", "examples", "effect_source", "agent_source_refs",
"positionals", "result", "examples", "effect_source", "agent_source_refs",
"field_provenance", "path", "source", "product_id", "display", "is_alias",
} {
delete(payload, key)
+152
View File
@@ -0,0 +1,152 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package cli
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func TestResultContractModelWireRoundTripAndCompactPolicy(t *testing.T) {
result := &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeFailure, contract.ResultOutcomeSuccess},
DataSchema: json.RawMessage(`{ "type":"object", "properties":{"items":{"type":"array","description":"Business result records","items":{"type":"object"}}} }`),
SensitivePaths: []string{"items.secret", "credential"},
}
spec, err := ToolSpecFromRuntime(RuntimeToolSpecInput{
Identity: contract.ToolIdentitySpec{ProductID: "dev", Name: "list", CLIName: "list", CLIPath: "dev list"},
Parameters: []ParameterSpec{{Name: "cursor", Type: "string"}},
Result: result,
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
})
if err != nil {
t.Fatalf("ToolSpecFromRuntime() error = %v", err)
}
if got, want := spec.Result.Outcomes, []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}; !reflect.DeepEqual(got, want) {
t.Fatalf("outcomes = %#v, want %#v", got, want)
}
result.Outcomes[0] = contract.ResultOutcomePending
result.DataSchema[0] = '['
if spec.Result.Outcomes[0] != contract.ResultOutcomeSuccess || spec.Result.DataSchema[0] != '{' {
t.Fatal("ToolSpec result aliases runtime input")
}
payload, err := spec.ToPayload()
if err != nil {
t.Fatalf("ToPayload() error = %v", err)
}
resultPayload, ok := payload["result"].(map[string]any)
if !ok || schemaString(resultPayload["data_schema"].(map[string]any)["type"]) != "object" {
t.Fatalf("result payload = %#v", payload["result"])
}
if _, exists := specResultSummary(t, spec)["result"]; exists {
t.Fatal("result must remain full-leaf-only")
}
compactResult, exists := stripSchemaPayloadCompact(payload)["result"].(map[string]any)
if !exists {
t.Fatal("compact leaf must include the reviewed result contract")
}
if outcomes, ok := compactResult["outcomes"].([]any); !ok || len(outcomes) != 2 {
t.Fatalf("compact result outcomes = %#v", compactResult["outcomes"])
}
if dataSchema, ok := compactResult["data_schema"].(map[string]any); !ok || schemaString(dataSchema["type"]) != "object" {
t.Fatalf("compact result data_schema = %#v", compactResult["data_schema"])
}
if !reflect.DeepEqual(compactResult, resultPayload) {
t.Fatalf("compact result must equal full-leaf result\ncompact: %#v\nfull: %#v", compactResult, resultPayload)
}
compactPagination, exists := stripSchemaPayloadCompact(payload)["pagination"].(map[string]any)
if !exists || schemaString(compactPagination["meta_path"]) != contract.PaginationMetaPath || schemaString(compactPagination["cursor_parameter"]) != "cursor" {
t.Fatalf("compact pagination = %#v", compactPagination)
}
wire, err := schemaToolWireFromPayload(payload)
if err != nil {
t.Fatalf("schemaToolWireFromPayload() error = %v", err)
}
roundTrip, err := schemaToolSpecFromWire(wire)
if err != nil {
t.Fatalf("schemaToolSpecFromWire() error = %v", err)
}
roundTripPayload, err := roundTrip.ToPayload()
if err != nil {
t.Fatalf("round-trip ToPayload() error = %v", err)
}
if !schemaJSONEqual(payload, roundTripPayload) {
t.Fatalf("result changed across wire round-trip\nfirst: %#v\nround: %#v", payload["result"], roundTripPayload["result"])
}
}
func specResultSummary(t *testing.T, spec ToolSpec) map[string]any {
t.Helper()
payload, err := spec.ToSummaryPayload()
if err != nil {
t.Fatalf("ToSummaryPayload() error = %v", err)
}
return payload
}
func TestToolWithoutResultKeepsResultAbsent(t *testing.T) {
spec, err := ToolSpecFromRuntime(RuntimeToolSpecInput{
Identity: contract.ToolIdentitySpec{ProductID: "dev", Name: "legacy", CLIName: "legacy", CLIPath: "dev legacy"},
})
if err != nil {
t.Fatal(err)
}
payload, err := spec.ToPayload()
if err != nil {
t.Fatal(err)
}
if _, exists := payload["result"]; exists {
t.Fatal("tool without Result gained a result key")
}
}
func TestToolSpecRejectsInvalidResultInsteadOfDroppingIt(t *testing.T) {
_, err := ToolSpecFromRuntime(RuntimeToolSpecInput{
Identity: contract.ToolIdentitySpec{ProductID: "dev", Name: "invalid", CLIName: "invalid", CLIPath: "dev invalid"},
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
DataSchema: json.RawMessage(`[]`),
},
})
if err == nil {
t.Fatal("invalid result schema was silently dropped")
}
}
func TestToolSpecRejectsInvalidOrUndeclaredPaginationCursor(t *testing.T) {
identity := contract.ToolIdentitySpec{ProductID: "dev", Name: "list", CLIName: "list", CLIPath: "dev list"}
for _, tc := range []struct {
name string
parameters []ParameterSpec
pagination *contract.PaginationSpec
want string
}{
{
name: "invalid pagination declaration",
parameters: []ParameterSpec{{Name: "cursor", Type: "string"}},
pagination: &contract.PaginationSpec{Kind: "offset", CursorParameter: "cursor"},
want: "unsupported kind",
},
{
name: "cursor is not a parameter",
pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
want: "is not a declared parameter",
},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := ToolSpecFromRuntime(RuntimeToolSpecInput{
Identity: identity, Parameters: tc.parameters, Pagination: tc.pagination,
})
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("ToolSpecFromRuntime() error = %v, want %q", err, tc.want)
}
})
}
}
+12
View File
@@ -11,6 +11,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/spf13/cobra"
)
@@ -334,6 +335,15 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
provenance := contractFinalProvenance(identity, title, description, titleProv, descriptionProv, safety, interfaceSpec, selection, final.DryRun)
result, pagination := final.Result, final.Pagination
if !output.UsesUnifiedResult(entry.Command) {
// ResultSpec describes the unified envelope data value and PaginationSpec
// describes meta.pagination. Keep both declarations internal while a
// command still emits legacy bytes or only shadow-validates the new
// contract; publishing them early makes Schema disagree with runtime.
result, pagination = nil, nil
}
return ToolSpecFromRuntime(RuntimeToolSpecInput{
Identity: identity,
Display: entry.ProductName,
@@ -344,6 +354,8 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
Constraints: constraints,
Positionals: positionals,
DryRun: final.DryRun,
Result: result,
Pagination: pagination,
Safety: safety,
Interface: interfaceSpec,
Selection: selection,
+4
View File
@@ -65,6 +65,8 @@ type schemaToolWire struct {
Constraints RuntimeSchemaConstraints `json:"constraints"`
Positionals []contract.RuntimeSchemaPositional `json:"positionals"`
DryRun *contract.DryRunSpec `json:"dry_run"`
Result *contract.ResultSpec `json:"result"`
Pagination *contract.PaginationSpec `json:"pagination"`
Effect string `json:"effect"`
EffectSource string `json:"effect_source"`
Risk string `json:"risk"`
@@ -267,6 +269,8 @@ func schemaToolSpecFromWire(wire schemaToolWire) (ToolSpec, error) {
Constraints: wire.Constraints,
Positionals: wire.Positionals,
DryRun: wire.DryRun,
Result: wire.Result,
Pagination: wire.Pagination,
Safety: contract.SafetySpec{
Effect: wire.Effect,
EffectSource: wire.EffectSource,
+2
View File
@@ -30,6 +30,8 @@ type ContractFinalPayload struct {
Parameters []ParamDecl
Safety *SafetySpec
DryRun *DryRunSpec
Result *ResultSpec
Pagination *PaginationSpec
Interface *InterfaceSpec
Selection *SelectionSpec
Identity *ToolIdentitySpec
+91
View File
@@ -0,0 +1,91 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package contract
import (
"encoding/json"
"reflect"
"strings"
"testing"
)
func TestNormalizeResultSpecCanonicalizesAndCopies(t *testing.T) {
in := &ResultSpec{
Outcomes: []ResultOutcome{ResultOutcomeFailure, ResultOutcomeSuccess},
DataSchema: json.RawMessage(`{ "properties": {"items":{"type":"array","description":"Result records","items":{"type":"object"}}}, "type":"object" }`),
SensitivePaths: []string{"items.secret", "credential"},
}
got, err := NormalizeResultSpec(in, "dev.list")
if err != nil {
t.Fatalf("NormalizeResultSpec() error = %v", err)
}
if want := []ResultOutcome{ResultOutcomeSuccess, ResultOutcomeFailure}; !reflect.DeepEqual(got.Outcomes, want) {
t.Fatalf("outcomes = %#v, want %#v", got.Outcomes, want)
}
if string(got.DataSchema) != `{"properties":{"items":{"type":"array","description":"Result records","items":{"type":"object"}}},"type":"object"}` {
t.Fatalf("data_schema = %s", got.DataSchema)
}
if want := []string{"credential", "items.secret"}; !reflect.DeepEqual(got.SensitivePaths, want) {
t.Fatalf("sensitive_paths = %#v, want %#v", got.SensitivePaths, want)
}
in.Outcomes[0] = ResultOutcomePending
in.DataSchema[0] = '['
in.SensitivePaths[0] = "changed"
if got.Outcomes[0] != ResultOutcomeSuccess || got.DataSchema[0] != '{' || got.SensitivePaths[0] != "credential" {
t.Fatalf("normalized result aliases input: %#v", got)
}
}
func TestNormalizeResultSpecRejectsInvalidContractsDeterministically(t *testing.T) {
valid := func() *ResultSpec {
return &ResultSpec{Outcomes: []ResultOutcome{ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object"}`)}
}
tests := []struct {
name string
edit func(*ResultSpec)
want string
}{
{"no outcomes", func(r *ResultSpec) { r.Outcomes = nil }, "no outcomes"},
{"unknown outcome", func(r *ResultSpec) { r.Outcomes = []ResultOutcome{"ok"} }, "unknown outcome"},
{"duplicate outcome", func(r *ResultSpec) { r.Outcomes = []ResultOutcome{ResultOutcomeSuccess, ResultOutcomeSuccess} }, "duplicate outcome"},
{"schema array", func(r *ResultSpec) { r.DataSchema = json.RawMessage(`[]`) }, "data_schema: must be one JSON object"},
{"multiple schemas", func(r *ResultSpec) { r.DataSchema = json.RawMessage(`{} {}`) }, "data_schema: must be one JSON object"},
{"missing property description", func(r *ResultSpec) {
r.DataSchema = json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}}}`)
}, "properties.id requires description"},
{"unsafe path", func(r *ResultSpec) { r.SensitivePaths = []string{"$.token"} }, "unsafe segment"},
{"duplicate path", func(r *ResultSpec) { r.SensitivePaths = []string{"token", " token "} }, "duplicate sensitive path"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
spec := valid()
test.edit(spec)
_, err := NormalizeResultSpec(spec, "dev.test")
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want %q", err, test.want)
}
})
}
}
func TestNormalizePaginationSpecUsesFrameworkMetaPaths(t *testing.T) {
got, err := NormalizePaginationSpec(&PaginationSpec{Kind: PaginationKindCursor, CursorParameter: "--cursor"}, "dev.list")
if err != nil {
t.Fatal(err)
}
if got.CursorParameter != "cursor" || got.MetaPath != PaginationMetaPath || got.EndpointExhaustedPath != PaginationExhaustedPath || got.NextTokenPath != PaginationNextTokenPath {
t.Fatalf("pagination = %#v", got)
}
for _, spec := range []*PaginationSpec{
{Kind: "offset", CursorParameter: "cursor"},
{Kind: PaginationKindCursor},
{Kind: PaginationKindCursor, CursorParameter: "cursor", MetaPath: "data.pagination"},
} {
if _, err := NormalizePaginationSpec(spec, "dev.list"); err == nil {
t.Fatalf("invalid pagination accepted: %#v", spec)
}
}
}
+239
View File
@@ -14,7 +14,10 @@
package contract
import (
"bytes"
"encoding/json"
"fmt"
"io"
"sort"
"strings"
)
@@ -59,6 +62,242 @@ type DryRunSpec struct {
RemoteReads bool `json:"remote_reads,omitempty"`
}
// ResultOutcome is one closed unified-output envelope outcome.
type ResultOutcome string
const (
ResultOutcomeSuccess ResultOutcome = "success"
ResultOutcomePending ResultOutcome = "pending"
ResultOutcomePartialFailure ResultOutcome = "partial_failure"
ResultOutcomeFailure ResultOutcome = "failure"
)
var canonicalResultOutcomes = [...]ResultOutcome{
ResultOutcomeSuccess,
ResultOutcomePending,
ResultOutcomePartialFailure,
ResultOutcomeFailure,
}
const (
PaginationKindCursor = "cursor"
PaginationMetaPath = "meta.pagination"
PaginationExhaustedPath = "meta.pagination.endpoint_exhausted"
PaginationNextTokenPath = "meta.pagination.next_token"
)
// PaginationSpec is a command-level declaration for framework pagination
// metadata. It is deliberately separate from ResultSpec because pagination is
// emitted under envelope meta, not inside the business response data.
type PaginationSpec struct {
Kind string `json:"kind"`
CursorParameter string `json:"cursor_parameter"`
MetaPath string `json:"meta_path"`
EndpointExhaustedPath string `json:"endpoint_exhausted_path"`
NextTokenPath string `json:"next_token_path"`
}
// ResultSpec is the reviewed return-value contract for one command and is
// projected unchanged into both full-leaf and compact-leaf Schema. Outcomes
// and DataSchema are required; Pagination and SensitivePaths are omitted when
// absent. DataSchema is a canonical recursive JSON Schema object; every path
// is relative to the unified-output envelope data value.
type ResultSpec struct {
Outcomes []ResultOutcome `json:"outcomes"`
DataSchema json.RawMessage `json:"data_schema"`
SensitivePaths []string `json:"sensitive_paths,omitempty"`
}
// NormalizeResultSpec returns a validated, canonical, defensively copied
// result contract. It is shared by declaration, ToolSpec, and snapshot paths.
func NormalizeResultSpec(in *ResultSpec, canonical string) (*ResultSpec, error) {
if in == nil {
return nil, nil
}
canonical = defaultString(strings.TrimSpace(canonical), "<unknown>")
out := &ResultSpec{}
seenOutcomes := make(map[ResultOutcome]bool, len(in.Outcomes))
for _, outcome := range in.Outcomes {
outcome = ResultOutcome(strings.TrimSpace(string(outcome)))
valid := false
for _, allowed := range canonicalResultOutcomes {
if outcome == allowed {
valid = true
break
}
}
if !valid {
return nil, fmt.Errorf("schema tool %s result has unknown outcome %q", canonical, outcome)
}
if seenOutcomes[outcome] {
return nil, fmt.Errorf("schema tool %s result has duplicate outcome %q", canonical, outcome)
}
seenOutcomes[outcome] = true
}
if len(seenOutcomes) == 0 {
return nil, fmt.Errorf("schema tool %s result has no outcomes", canonical)
}
for _, outcome := range canonicalResultOutcomes {
if seenOutcomes[outcome] {
out.Outcomes = append(out.Outcomes, outcome)
}
}
var err error
out.DataSchema, err = canonicalJSONObject(in.DataSchema)
if err != nil {
return nil, fmt.Errorf("schema tool %s result data_schema: %w", canonical, err)
}
if err := validateResultSchemaDescriptions(out.DataSchema, "data_schema"); err != nil {
return nil, fmt.Errorf("schema tool %s result %w", canonical, err)
}
seenPaths := make(map[string]bool, len(in.SensitivePaths))
for _, path := range in.SensitivePaths {
path = strings.TrimSpace(path)
if err := validateResultPath(path); err != nil {
return nil, fmt.Errorf("schema tool %s result sensitive path: %w", canonical, err)
}
if seenPaths[path] {
return nil, fmt.Errorf("schema tool %s result has duplicate sensitive path %q", canonical, path)
}
seenPaths[path] = true
out.SensitivePaths = append(out.SensitivePaths, path)
}
sort.Strings(out.SensitivePaths)
if len(out.SensitivePaths) == 0 {
out.SensitivePaths = nil
}
return out, nil
}
// NormalizePaginationSpec validates the command-specific input parameter and
// fills the framework-owned public meta paths.
func NormalizePaginationSpec(in *PaginationSpec, canonical string) (*PaginationSpec, error) {
if in == nil {
return nil, nil
}
canonical = defaultString(strings.TrimSpace(canonical), "<unknown>")
kind := strings.TrimSpace(in.Kind)
if kind != PaginationKindCursor {
return nil, fmt.Errorf("schema tool %s pagination has unsupported kind %q", canonical, kind)
}
cursorParameter := strings.TrimSpace(strings.TrimPrefix(in.CursorParameter, "--"))
if cursorParameter == "" || strings.Contains(cursorParameter, ".") {
return nil, fmt.Errorf("schema tool %s pagination cursor_parameter must name one CLI flag", canonical)
}
provided := []struct{ name, got, want string }{
{"meta_path", strings.TrimSpace(in.MetaPath), PaginationMetaPath},
{"endpoint_exhausted_path", strings.TrimSpace(in.EndpointExhaustedPath), PaginationExhaustedPath},
{"next_token_path", strings.TrimSpace(in.NextTokenPath), PaginationNextTokenPath},
}
for _, field := range provided {
if field.got != "" && field.got != field.want {
return nil, fmt.Errorf("schema tool %s pagination %s is framework-owned and must be %q", canonical, field.name, field.want)
}
}
return &PaginationSpec{
Kind: kind,
CursorParameter: cursorParameter,
MetaPath: PaginationMetaPath,
EndpointExhaustedPath: PaginationExhaustedPath,
NextTokenPath: PaginationNextTokenPath,
}, nil
}
func canonicalJSONObject(raw json.RawMessage) (json.RawMessage, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("must be one JSON object")
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var object map[string]json.RawMessage
if err := decoder.Decode(&object); err != nil || object == nil {
return nil, fmt.Errorf("must be one JSON object")
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
return nil, fmt.Errorf("must be one JSON object")
}
canonical, _ := json.Marshal(object) // decoded RawMessages are always marshalable
return json.RawMessage(canonical), nil
}
// validateResultSchemaDescriptions keeps the Agent-facing return contract
// self-explanatory. Every named property needs a description; nested object
// properties and array items are checked recursively. The root schema and
// anonymous composition branches do not need descriptions because they are
// not field names an Agent must interpret.
func validateResultSchemaDescriptions(raw json.RawMessage, location string) error {
var schema map[string]any
if err := json.Unmarshal(raw, &schema); err != nil {
return fmt.Errorf("%s must be one JSON Schema object", location)
}
return validateResultSchemaNode(schema, location)
}
func validateResultSchemaNode(schema map[string]any, location string) error {
if rawProperties, exists := schema["properties"]; exists {
properties, ok := rawProperties.(map[string]any)
if !ok {
return fmt.Errorf("%s.properties must be an object", location)
}
for name, rawProperty := range properties {
property, ok := rawProperty.(map[string]any)
if !ok {
return fmt.Errorf("%s.properties.%s must be a JSON Schema object", location, name)
}
description, _ := property["description"].(string)
if strings.TrimSpace(description) == "" {
return fmt.Errorf("%s.properties.%s requires description", location, name)
}
if err := validateResultSchemaNode(property, location+".properties."+name); err != nil {
return err
}
}
}
if rawItems, exists := schema["items"]; exists {
items, ok := rawItems.(map[string]any)
if !ok {
return fmt.Errorf("%s.items must be a JSON Schema object", location)
}
if err := validateResultSchemaNode(items, location+".items"); err != nil {
return err
}
}
for _, keyword := range []string{"allOf", "anyOf", "oneOf"} {
rawBranches, exists := schema[keyword]
if !exists {
continue
}
branches, ok := rawBranches.([]any)
if !ok {
return fmt.Errorf("%s.%s must be an array", location, keyword)
}
for index, rawBranch := range branches {
branch, ok := rawBranch.(map[string]any)
if !ok {
return fmt.Errorf("%s.%s[%d] must be a JSON Schema object", location, keyword, index)
}
if err := validateResultSchemaNode(branch, fmt.Sprintf("%s.%s[%d]", location, keyword, index)); err != nil {
return err
}
}
}
return nil
}
func validateResultPath(path string) error {
if path == "" || strings.HasPrefix(path, ".") || strings.HasSuffix(path, ".") || strings.Contains(path, "..") {
return fmt.Errorf("path %q is not a relative data path", path)
}
for _, segment := range strings.Split(path, ".") {
for i, r := range segment {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '_' || (i > 0 && (r == '-' || (r >= '0' && r <= '9')))) {
return fmt.Errorf("path %q contains unsafe segment %q", path, segment)
}
}
}
return nil
}
const (
DryRunPreviewInvocation = "invocation"
DryRunPreviewRequest = "request"
@@ -4,10 +4,75 @@
package contract
import (
"encoding/json"
"strings"
"testing"
)
func TestFrameworkResultSpecValidationEdges(t *testing.T) {
if got, err := NormalizeResultSpec(nil, ""); err != nil || got != nil {
t.Fatalf("NormalizeResultSpec(nil)=(%v,%v)", got, err)
}
base := func() *ResultSpec {
return &ResultSpec{Outcomes: []ResultOutcome{ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object"}`)}
}
if got, err := NormalizeResultSpec(base(), ""); err != nil || got == nil || got.SensitivePaths != nil {
t.Fatalf("valid default spec=(%#v,%v)", got, err)
}
cases := []struct {
name string
edit func(*ResultSpec)
}{
{"sensitive invalid", func(s *ResultSpec) { s.SensitivePaths = []string{"bad..path"} }},
{"empty schema", func(s *ResultSpec) { s.DataSchema = nil }},
{"multiple schema", func(s *ResultSpec) { s.DataSchema = json.RawMessage(`{} {}`) }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
spec := base()
tc.edit(spec)
if _, err := NormalizeResultSpec(spec, "sample"); err == nil {
t.Fatalf("invalid spec accepted: %#v", spec)
}
})
}
if err := validateResultPath("a.$"); err == nil {
t.Fatal("unsafe segment accepted")
}
}
func TestFrameworkResultSchemaDescriptionValidationEdges(t *testing.T) {
for _, tc := range []struct {
name string
raw string
want string
}{
{"invalid json", `{`, "must be one JSON Schema object"},
{"properties is not object", `{"properties":[]}`, "properties must be an object"},
{"property is not schema", `{"properties":{"id":"string"}}`, "properties.id must be a JSON Schema object"},
{"property description missing", `{"properties":{"id":{"type":"string"}}}`, "properties.id requires description"},
{"nested property invalid", `{"properties":{"item":{"description":"item","properties":[]}}}`, "properties.item.properties must be an object"},
{"items is not schema", `{"items":[]}`, "items must be a JSON Schema object"},
{"nested items invalid", `{"items":{"properties":[]}}`, "items.properties must be an object"},
{"composition is not array", `{"oneOf":{}}`, "oneOf must be an array"},
{"composition branch is not schema", `{"anyOf":["string"]}`, "anyOf[0] must be a JSON Schema object"},
{"nested composition invalid", `{"allOf":[{"properties":[]}]}`, "allOf[0].properties must be an object"},
} {
t.Run(tc.name, func(t *testing.T) {
err := validateResultSchemaDescriptions(json.RawMessage(tc.raw), "data_schema")
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("validation error = %v, want %q", err, tc.want)
}
})
}
}
func TestNormalizePaginationSpecNilIsAbsent(t *testing.T) {
if got, err := NormalizePaginationSpec(nil, ""); err != nil || got != nil {
t.Fatalf("NormalizePaginationSpec(nil) = (%#v, %v)", got, err)
}
}
func TestCrossPlatformCoverageDryRunSpecValidate(t *testing.T) {
for _, kind := range []string{DryRunPreviewInvocation, DryRunPreviewRequest, DryRunPreviewPlan, DryRunPreviewDiff} {
if err := (DryRunSpec{PreviewKind: kind}).Validate("sample.run"); err != nil {
+8
View File
@@ -42,6 +42,8 @@ type ContractDecl struct {
Positionals []contract.RuntimeSchemaPositional
Parameters []contract.ParamDecl
DryRun *contract.DryRunSpec
Result *contract.ResultSpec
Pagination *contract.PaginationSpec
Interface *contract.InterfaceSpec
Selection contract.SelectionSpec
Identity contract.ToolIdentitySpec
@@ -144,6 +146,12 @@ func (s ContractDecl) empty() bool {
if s.DryRun != nil && strings.TrimSpace(s.DryRun.PreviewKind) != "" {
return false
}
if s.Result != nil {
return false
}
if s.Pagination != nil {
return false
}
if s.Interface != nil {
iface := s.Interface
if strings.TrimSpace(iface.Mode) != "" || strings.TrimSpace(iface.Availability) != "" ||
+59
View File
@@ -42,6 +42,11 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
Description: "Create Desc",
Positionals: []contract.RuntimeSchemaPositional{{Name: "id", Required: true, Index: 0}},
DryRun: &contract.DryRunSpec{PreviewKind: "invocation", RemoteReads: true},
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
DataSchema: []byte(`{"type":"object"}`),
},
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
@@ -79,6 +84,12 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
if final.DryRun == nil || final.DryRun.PreviewKind != "invocation" || !final.DryRun.RemoteReads {
t.Fatalf("dry_run = %#v", final.DryRun)
}
if final.Result == nil || len(final.Result.Outcomes) != 1 {
t.Fatalf("result = %#v", final.Result)
}
if final.Pagination == nil || final.Pagination.CursorParameter != "cursor" || final.Pagination.MetaPath != contract.PaginationMetaPath {
t.Fatalf("pagination = %#v", final.Pagination)
}
if final.Interface == nil || final.Interface.Mode != "mcp" || final.Interface.Ref == nil || final.Interface.Ref.RPCName != "create_thing" {
t.Fatalf("interface = %#v", final.Interface)
}
@@ -110,6 +121,54 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
}
}
func TestFrameworkContractDeclResultMarksNonEmptyAndRejectsInvalidSchema(t *testing.T) {
if (ContractDecl{Result: &contract.ResultSpec{}}).Empty() {
t.Fatal("Result declaration was treated as empty")
}
defer func() {
if recovered := recover(); recovered == nil || !strings.Contains(recovered.(string), "invalid Contract.Result") {
t.Fatalf("panic=%v", recovered)
}
}()
New(Spec{
Use: "bad-result",
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: ContractDecl{
Title: "Bad", Description: "bad result",
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}},
Interface: &contract.InterfaceSpec{Mode: "local", Availability: "available"},
Selection: contract.SelectionSpec{AgentSummary: "bad", UseWhen: []string{"bad"}, AvoidWhen: []string{"good"}, Examples: []string{"dws bad-result"}},
Identity: contract.ToolIdentitySpec{ProductID: "sample", Name: "bad", CanonicalPath: "sample.bad", CLIPath: "bad-result", PrimaryCLIPath: "bad-result"},
},
Invoke: func(*Ctx, map[string]any) error { return nil },
})
}
func TestFrameworkContractDeclPaginationMarksNonEmptyAndRejectsInvalidSpec(t *testing.T) {
if (ContractDecl{Pagination: &contract.PaginationSpec{}}).Empty() {
t.Fatal("Pagination declaration was treated as empty")
}
defer func() {
recovered := recover()
if recovered == nil || !strings.Contains(recovered.(string), "invalid Contract.Pagination") {
t.Fatalf("panic=%v", recovered)
}
}()
New(Spec{
Use: "bad-pagination",
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: ContractDecl{
Title: "Bad pagination",
Description: "bad pagination",
Pagination: &contract.PaginationSpec{Kind: "offset", CursorParameter: "cursor"},
Interface: &contract.InterfaceSpec{Mode: "local", Availability: "available"},
Selection: contract.SelectionSpec{AgentSummary: "bad", UseWhen: []string{"bad"}, AvoidWhen: []string{"good"}, Examples: []string{"dws bad-pagination"}},
Identity: contract.ToolIdentitySpec{ProductID: "sample", Name: "bad_pagination", CanonicalPath: "sample.bad_pagination", CLIPath: "bad-pagination", PrimaryCLIPath: "bad-pagination"},
},
Invoke: func(*Ctx, map[string]any) error { return nil },
})
}
func TestNewCommandFallsBackToDeclaredDescriptionWithoutLong(t *testing.T) {
// Long wins when authored; without one the mandatory declaration supplies it.
cmd := New(Spec{
@@ -24,6 +24,11 @@ import (
func TestContractFinalTypedRegistryNoJSON(t *testing.T) {
cmd := &cobra.Command{Use: "x"}
t.Cleanup(func() { ClearRuntimeContractFinalForTest(cmd) })
result := &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
DataSchema: []byte(`{"type":"object"}`),
SensitivePaths: []string{"token"},
}
RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
Title: "T",
@@ -32,7 +37,11 @@ func TestContractFinalTypedRegistryNoJSON(t *testing.T) {
},
Selection: &contract.SelectionSpec{AgentSummary: "sum", UseWhen: []string{"u"}},
Identity: &contract.ToolIdentitySpec{ProductID: "p", Name: "n"},
Result: result,
})
result.Outcomes[0] = contract.ResultOutcomeFailure
result.DataSchema[0] = '['
result.SensitivePaths[0] = "changed"
if cmd.Annotations != nil {
if _, ok := cmd.Annotations["dws.schema.final"]; ok {
t.Fatal("must not write JSON annotation dws.schema.final")
@@ -45,6 +54,14 @@ func TestContractFinalTypedRegistryNoJSON(t *testing.T) {
if got.Selection == nil || got.Selection.Reviewed != nil {
t.Fatalf("selection must not carry reviewed fields: %#v", got.Selection)
}
if got.Result == nil || got.Result.Outcomes[0] != contract.ResultOutcomeSuccess || got.Result.DataSchema[0] != '{' || got.Result.SensitivePaths[0] != "token" {
t.Fatalf("stored result aliases registration input: %#v", got.Result)
}
got.Result.Outcomes[0] = contract.ResultOutcomeFailure
again, _ := RuntimeContractFinal(cmd)
if again.Result.Outcomes[0] != contract.ResultOutcomeSuccess {
t.Fatal("RuntimeContractFinal result aliases stored payload")
}
}
func TestCrossPlatformCoverageContractFinalNilCommandGuards(t *testing.T) {
@@ -137,3 +154,96 @@ func TestCrossPlatformCoverageRuntimeContractFinalRejectsForeignStoredValue(t *t
t.Fatal("typed nil payload must not decode as contract.ContractFinalPayload")
}
}
func TestResolveRuntimeSafetyUsesCanonicalOrCLIIdentityAndRejectsUnavailable(t *testing.T) {
read := &cobra.Command{Use: "read"}
t.Cleanup(func() { ClearRuntimeContractFinalForTest(read) })
RegisterRuntimeContractFinal(read, contract.ContractFinalPayload{
Identity: &contract.ToolIdentitySpec{CanonicalPath: "sample.read", PrimaryCLIPath: "sample get"},
Safety: &contract.SafetySpec{Effect: " read ", Idempotency: " idempotent "},
})
for _, lookup := range []struct {
canonical string
cli string
}{
{canonical: "sample.read"},
{canonical: "different.rpc", cli: "dws sample get"},
} {
safety, declared, ok := ResolveRuntimeSafety(lookup.canonical, lookup.cli)
if !declared || !ok || safety.Effect != "read" || safety.Idempotency != "idempotent" {
t.Fatalf("ResolveRuntimeSafety(%q, %q) = %#v, %v, %v", lookup.canonical, lookup.cli, safety, declared, ok)
}
}
missingSafety := &cobra.Command{Use: "write"}
t.Cleanup(func() { ClearRuntimeContractFinalForTest(missingSafety) })
RegisterRuntimeContractFinal(missingSafety, contract.ContractFinalPayload{
Identity: &contract.ToolIdentitySpec{CanonicalPath: "sample.write"},
})
if _, declared, ok := ResolveRuntimeSafety("sample.write", ""); !declared || ok {
t.Fatalf("missing safety = declared %v ok %v, want true false", declared, ok)
}
if _, declared, ok := ResolveRuntimeSafety("legacy.call", "legacy call"); declared || ok {
t.Fatalf("legacy lookup = declared %v ok %v, want false false", declared, ok)
}
}
func boolPointer(value bool) *bool { return &value }
func intPointer(value int) *int { return &value }
func TestFrameworkContractFinalDeepCopyAndSafetyConflicts(t *testing.T) {
cmd := &cobra.Command{Use: "all"}
t.Cleanup(func() { ClearRuntimeContractFinalForTest(cmd) })
payload := contract.ContractFinalPayload{
Positionals: []contract.RuntimeSchemaPositional{{Name: "id"}},
Parameters: []contract.ParamDecl{{Name: "mode", Enum: []string{"a"}, Required: boolPointer(true)}},
Safety: &contract.SafetySpec{Effect: " read ", EffectSource: " source ", Risk: " low ", Confirmation: " not_required ", Idempotency: " idempotent "},
DryRun: &contract.DryRunSpec{PreviewKind: "plan"},
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
DataSchema: []byte(`{"type":"object"}`), SensitivePaths: []string{"token"},
},
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
Interface: &contract.InterfaceSpec{Ref: &contract.InterfaceRefSpec{}},
Selection: &contract.SelectionSpec{
UseWhen: []string{"use"}, AvoidWhen: []string{"avoid"}, Prerequisites: []string{"pre"}, Tips: []string{"tip"},
WorkflowRefs: []string{"flow"}, Examples: []string{"example"}, SourceRefs: []string{"source"},
ExampleDispositions: []contract.ExampleDisposition{{Index: intPointer(1)}}, Reviewed: boolPointer(true),
},
Identity: &contract.ToolIdentitySpec{CanonicalPath: "sample.all", Aliases: []string{"alias"}},
}
RegisterRuntimeContractFinal(cmd, payload)
got, ok := RuntimeContractFinal(cmd)
if !ok || got.Result == payload.Result || got.Pagination == payload.Pagination || got.Interface == payload.Interface || got.Selection == payload.Selection || got.Identity == payload.Identity {
t.Fatalf("payload not deeply cloned: %#v", got)
}
payload.Parameters[0].Enum[0] = "changed"
*payload.Parameters[0].Required = false
*payload.Selection.ExampleDispositions[0].Index = 9
*payload.Selection.Reviewed = false
again, _ := RuntimeContractFinal(cmd)
if again.Parameters[0].Enum[0] != "a" || !*again.Parameters[0].Required || *again.Selection.ExampleDispositions[0].Index != 1 || !*again.Selection.Reviewed {
t.Fatalf("stored payload aliased input: %#v", again)
}
matching := &cobra.Command{Use: "matching"}
t.Cleanup(func() { ClearRuntimeContractFinalForTest(matching) })
RegisterRuntimeContractFinal(matching, contract.ContractFinalPayload{Identity: &contract.ToolIdentitySpec{Path: "sample.all", CLIPath: "sample all"}, Safety: &contract.SafetySpec{Effect: "read", EffectSource: "source", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}})
if _, declared, valid := ResolveRuntimeSafety("sample.all", ""); !declared || !valid {
t.Fatalf("equivalent duplicate=(declared=%v valid=%v)", declared, valid)
}
conflict := &cobra.Command{Use: "conflict"}
t.Cleanup(func() { ClearRuntimeContractFinalForTest(conflict) })
RegisterRuntimeContractFinal(conflict, contract.ContractFinalPayload{Identity: &contract.ToolIdentitySpec{CanonicalPath: "sample.all"}, Safety: &contract.SafetySpec{Effect: "write"}})
if _, declared, valid := ResolveRuntimeSafety("sample.all", ""); !declared || valid {
t.Fatalf("conflict=(declared=%v valid=%v)", declared, valid)
}
if runtimeIdentityMatches(contract.ToolIdentitySpec{}, "", "") {
t.Fatal("empty identity matched")
}
if got := cloneSlice[string](nil); got != nil {
t.Fatalf("cloneSlice(nil)=%v", got)
}
}
+144 -2
View File
@@ -14,6 +14,7 @@
package contractfinal
import (
"strings"
"sync"
"github.com/spf13/cobra"
@@ -34,7 +35,7 @@ func RegisterRuntimeContractFinal(cmd *cobra.Command, payload contract.ContractF
return
}
runtimeannotate.AnnotateRuntimeContract(cmd)
p := payload
p := cloneContractFinalPayload(payload)
contractFinalByCommand.Store(cmd, &p)
}
@@ -51,7 +52,84 @@ func RuntimeContractFinal(cmd *cobra.Command) (contract.ContractFinalPayload, bo
if !ok || p == nil {
return contract.ContractFinalPayload{}, false
}
return *p, true
return cloneContractFinalPayload(*p), true
}
func cloneContractFinalPayload(in contract.ContractFinalPayload) contract.ContractFinalPayload {
out := in
out.Positionals = cloneSlice(in.Positionals)
out.Parameters = cloneSlice(in.Parameters)
for i := range out.Parameters {
out.Parameters[i].Enum = cloneSlice(in.Parameters[i].Enum)
if in.Parameters[i].Required != nil {
required := *in.Parameters[i].Required
out.Parameters[i].Required = &required
}
}
if in.Safety != nil {
value := *in.Safety
out.Safety = &value
}
if in.DryRun != nil {
value := *in.DryRun
out.DryRun = &value
}
if in.Result != nil {
value := *in.Result
value.Outcomes = cloneSlice(in.Result.Outcomes)
value.DataSchema = cloneSlice(in.Result.DataSchema)
value.SensitivePaths = cloneSlice(in.Result.SensitivePaths)
out.Result = &value
}
if in.Pagination != nil {
value := *in.Pagination
out.Pagination = &value
}
if in.Interface != nil {
value := *in.Interface
if in.Interface.Ref != nil {
ref := *in.Interface.Ref
value.Ref = &ref
}
out.Interface = &value
}
if in.Selection != nil {
value := *in.Selection
value.UseWhen = cloneSlice(in.Selection.UseWhen)
value.AvoidWhen = cloneSlice(in.Selection.AvoidWhen)
value.Prerequisites = cloneSlice(in.Selection.Prerequisites)
value.Tips = cloneSlice(in.Selection.Tips)
value.WorkflowRefs = cloneSlice(in.Selection.WorkflowRefs)
value.Examples = cloneSlice(in.Selection.Examples)
value.SourceRefs = cloneSlice(in.Selection.SourceRefs)
value.ExampleDispositions = cloneSlice(in.Selection.ExampleDispositions)
for i := range value.ExampleDispositions {
if in.Selection.ExampleDispositions[i].Index != nil {
index := *in.Selection.ExampleDispositions[i].Index
value.ExampleDispositions[i].Index = &index
}
}
if in.Selection.Reviewed != nil {
reviewed := *in.Selection.Reviewed
value.Reviewed = &reviewed
}
out.Selection = &value
}
if in.Identity != nil {
value := *in.Identity
value.Aliases = cloneSlice(in.Identity.Aliases)
out.Identity = &value
}
return out
}
func cloneSlice[T any](in []T) []T {
if in == nil {
return nil
}
out := make([]T, len(in))
copy(out, in)
return out
}
// HasRuntimeContractFinal reports whether the leaf has a registered final overlay.
@@ -62,3 +140,67 @@ func HasRuntimeContractFinal(cmd *cobra.Command) bool {
_, ok := contractFinalByCommand.Load(cmd)
return ok
}
// ResolveRuntimeSafety finds the live ContractFinal safety declaration for an
// invocation identity. declared distinguishes a matched declaration whose
// safety is unavailable or conflicting from a legacy invocation with no unified
// declaration context. Repeated equivalent command-tree registrations are
// accepted; conflicting matches fail closed with ok=false.
func ResolveRuntimeSafety(canonicalPath, cliPath string) (safety contract.SafetySpec, declared, ok bool) {
canonicalPath = strings.TrimSpace(canonicalPath)
cliPath = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cliPath), "dws "))
var resolved contract.SafetySpec
contractFinalByCommand.Range(func(_, raw any) bool {
payload, valid := raw.(*contract.ContractFinalPayload)
if !valid || payload == nil || payload.Identity == nil ||
!runtimeIdentityMatches(*payload.Identity, canonicalPath, cliPath) {
return true
}
declared = true
if payload.Safety == nil {
ok = false
return false
}
candidate := normalizedRuntimeSafety(*payload.Safety)
if !ok {
resolved = candidate
ok = true
return true
}
if resolved != candidate {
ok = false
return false
}
return true
})
return resolved, declared, ok
}
func runtimeIdentityMatches(identity contract.ToolIdentitySpec, canonicalPath, cliPath string) bool {
if canonicalPath != "" {
for _, value := range []string{identity.CanonicalPath, identity.Path} {
if strings.TrimSpace(value) == canonicalPath {
return true
}
}
}
if cliPath == "" {
return false
}
for _, value := range []string{identity.PrimaryCLIPath, identity.CLIPath} {
if strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(value), "dws ")) == cliPath {
return true
}
}
return false
}
func normalizedRuntimeSafety(safety contract.SafetySpec) contract.SafetySpec {
safety.Effect = strings.TrimSpace(safety.Effect)
safety.EffectSource = strings.TrimSpace(safety.EffectSource)
safety.Risk = strings.TrimSpace(safety.Risk)
safety.Confirmation = strings.TrimSpace(safety.Confirmation)
safety.Idempotency = strings.TrimSpace(safety.Idempotency)
return safety
}
+41 -7
View File
@@ -63,6 +63,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
)
@@ -224,11 +225,12 @@ const (
// construction time. corecmd stays dispatch-agnostic and never calls a backend:
// the adapters (FromLeafSpec / FromShortcut) supply the body.
type Spec struct {
Use string
Short string
Long string
Example string
Hidden bool
Use string
Short string
Long string
Example string
Hidden bool
OutputRollout output.RolloutState
Flags []FlagSpec
Constraints []Constraint
@@ -266,6 +268,8 @@ type Spec struct {
RunE func(cmd *cobra.Command, args []string) error
// Invoke executes a single-step command with the assembled toolArgs.
Invoke func(c *Ctx, toolArgs map[string]any) error
// ResultInvoke executes once and returns an immutable framework 2.0 result.
ResultInvoke func(c *Ctx, toolArgs map[string]any) (output.CommandResult, error)
// Orchestrate executes a multi-step command; it assembles whatever payloads
// it needs from the Ctx.
Orchestrate func(c *Ctx) error
@@ -385,6 +389,9 @@ func New(spec Spec) *cobra.Command {
if spec.PostMount != nil {
spec.PostMount(cmd)
}
if spec.OutputRollout != "" {
output.SetCommandRollout(cmd, spec.OutputRollout)
}
if spec.ConfirmFirst {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
@@ -430,6 +437,16 @@ func New(spec Spec) *cobra.Command {
return err
}
}
if spec.ResultInvoke != nil {
if !output.UsesUnifiedResult(cmd) {
return fmt.Errorf("command %q uses ResultInvoke without an active unified-result rollout", cmd.CommandPath())
}
result, err := spec.ResultInvoke(ctx, toolArgs)
if err != nil {
return err
}
return output.StoreResult(cmd.Context(), result)
}
return spec.Invoke(ctx, toolArgs)
}
return cmd
@@ -486,12 +503,15 @@ func validateDispatchDecl(spec Spec) {
if spec.Invoke != nil {
declared++
}
if spec.ResultInvoke != nil {
declared++
}
if spec.Orchestrate != nil {
declared++
}
if declared != 1 {
panic(fmt.Sprintf(
"command %q must declare exactly one of RunE/Invoke/Orchestrate, got %d",
"command %q must declare exactly one of RunE/Invoke/Orchestrate, got %d (ResultInvoke is also a dispatcher)",
spec.Use, declared))
}
// ConfirmFirst only changes the ordering of a declared confirmation gate.
@@ -681,7 +701,7 @@ func ValidateRequired(cmd *cobra.Command, flags []FlagSpec) error {
if hint == "" {
hint = fmt.Sprintf("flag --%s is required", flag.Name)
}
return fmt.Errorf("%s", hint)
return apperrors.NewValidation(hint)
}
}
return nil
@@ -1360,6 +1380,20 @@ func AttachContract(cmd *cobra.Command, safety contract.SafetySpec, decl Contrac
d.PreviewKind = strings.TrimSpace(d.PreviewKind)
payload.DryRun = &d
}
if decl.Result != nil {
result, err := contract.NormalizeResultSpec(decl.Result, decl.Identity.CanonicalPath)
if err != nil {
panic(fmt.Sprintf("command %q has invalid Contract.Result: %v", cmd.Name(), err))
}
payload.Result = result
}
if decl.Pagination != nil {
pagination, err := contract.NormalizePaginationSpec(decl.Pagination, decl.Identity.CanonicalPath)
if err != nil {
panic(fmt.Sprintf("command %q has invalid Contract.Pagination: %v", cmd.Name(), err))
}
payload.Pagination = pagination
}
if decl.Interface != nil {
iface := &contract.InterfaceSpec{
Mode: strings.TrimSpace(decl.Interface.Mode),
+99
View File
@@ -0,0 +1,99 @@
package corecmd
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/spf13/cobra"
)
func TestResultInvokeCarriesOneFrameworkResult(t *testing.T) {
calls := 0
ctx, store := output.WithResultStore(context.Background())
cmd := New(Spec{
Use: "result",
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
calls++
return output.Success(map[string]any{"id": "a"}), nil
},
})
cmd.SetContext(ctx)
cmd.PersistentFlags().String("format", "json", "")
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatalf("calls=%d, want 1", calls)
}
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
t.Fatalf("stored code/emitted=%d/%v", code, emitted)
}
if !strings.Contains(stdout.String(), `"outcome": "success"`) || strings.Contains(stdout.String(), `"contract_version"`) {
t.Fatalf("stdout=%s", stdout.String())
}
if output.CommandRollout(cmd) != output.RolloutUnifiedActive {
t.Fatalf("rollout=%s", output.CommandRollout(cmd))
}
}
func TestFrameworkResultInvokeErrorLegacyAndStoreEdges(t *testing.T) {
wantErr := errors.New("invoke failed")
cases := []struct {
name string
rollout output.RolloutState
invoke func(*Ctx, map[string]any) (output.CommandResult, error)
want string
}{
{"invoke error", output.RolloutUnifiedActive, func(*Ctx, map[string]any) (output.CommandResult, error) { return nil, wantErr }, "invoke failed"},
{"legacy guard", output.RolloutLegacyOnly, func(*Ctx, map[string]any) (output.CommandResult, error) { return output.Success(nil), nil }, "without an active unified-result rollout"},
{"missing store", output.RolloutUnifiedActive, func(*Ctx, map[string]any) (output.CommandResult, error) { return output.Success(nil), nil }, "no result store"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd := New(Spec{Use: "result", OutputRollout: tc.rollout, Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}, ResultInvoke: tc.invoke})
cmd.SetArgs(nil)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Execute error=%v, want %q", err, tc.want)
}
})
}
}
func TestLegacyResultInvokeIsRejectedBeforeBusinessDispatch(t *testing.T) {
calls := 0
cmd := New(Spec{
Use: "result",
OutputRollout: output.RolloutLegacyOnly,
Safety: contract.SafetySpec{
Effect: "write", Risk: "high", Confirmation: "not_required", Idempotency: "unknown",
},
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
calls++
return output.Success(map[string]any{"changed": true}), nil
},
})
cmd.SetArgs(nil)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "without an active unified-result rollout") {
t.Fatalf("Execute error=%v", err)
}
if calls != 0 {
t.Fatalf("business dispatcher ran %d time(s), want 0", calls)
}
}
+5 -1
View File
@@ -54,7 +54,11 @@ func TestCrossPlatformCoverageDiagnosticsAndErrorRenderingEdges(t *testing.T) {
t.Cleanup(func() { marshalErrorJSON = oldMarshal })
marshalErrorJSON = func(any, string, string) ([]byte, error) { return nil, stderrors.New("encode") }
out.Reset()
if err := PrintJSON(&out, err); err != nil || !strings.Contains(out.String(), "failed to encode") {
if err := PrintJSON(&out, err); err != nil ||
!strings.Contains(out.String(), `"code":5`) ||
!strings.Contains(out.String(), `"category":"internal"`) ||
strings.Contains(out.String(), `"outcome"`) ||
strings.Contains(out.String(), `"type"`) {
t.Fatalf("PrintJSON fallback = %q, %v", out.String(), err)
}
+66 -3
View File
@@ -39,6 +39,41 @@ const (
CategoryValidation Category = "validation"
CategoryDiscovery Category = "discovery"
CategoryInternal Category = "internal"
// CategoryPartial is retained for source compatibility, but an error cannot
// reconstruct the per-item data required by a partial result. It therefore
// fails closed as internal; callers must use output.Partial for exit code 7.
CategoryPartial Category = "partial_failure"
)
// 退出码表(规划 v1.2 OQ-1 定案;契约规范 §4;轮10裁决⑬——保留现行码表,
// 仅新增 partial_failure 专用码,不做 wire 破坏性重排):
//
// 0 success / pending(异步受理不是失败)
// 1 api (CategoryAPI)
// 2 auth (CategoryAuth)
// 3 validation (CategoryValidation;confirmation_required 子类共享此码,
// 以 reason/subtype 区分,AC-13)
// 4 PAT (PATError 专属,见 pat.go ExitCodePermission;Category 不占用)
// 5 internal (CategoryInternal 与兜底:非结构化错误、panic 收敛均归 5)
// 6 discovery (CategoryDiscovery)
// 7 partial_failure(部分成功专用码,见 ExitCodePartial)
//
// ExitCodePartial is the partial-result exit code shared with internal/output.
// It is not returned for CategoryPartial errors because they lack the typed
// succeeded/failed/unknown payload required for an honest partial result.
const ExitCodePartial = 7
// 类别专属退出码常量(B171/B172,权威 = 规划 v1.2 OQ-1 定案,契约规范 §4)。
// ExitCode() 的 switch 用内联字面量,本组常量由 exitcodes.go 的
// exitCodeByCategory 映射表引用,值与内联字面量一一对应(同源不双轨)。
// 修改任一值必须先同步 ExitCode() 的 switch 分支与 internal/output 侧码表。
const (
ExitCodeAPI = 1
ExitCodeAuth = 2
ExitCodeValidation = 3
ExitCodeDiscovery = 6
ExitCodeInternal = 5
)
// Error is the structured repository-local error model for the Go rewrite.
@@ -83,6 +118,13 @@ type Option func(*Error)
// ExitCodePermission and the exit-code table in docs/reference.md);
// Discovery therefore uses 6 so hosts can tell "catalog lookup broke"
// apart from "PAT permission insufficient".
//
// confirmation_required 是 validation 的子类而非独立类别(B171,AC-13,
// 规划 v1.2 OQ-1 定案):门禁拦截错误挂 CategoryValidation 并以
// reason=confirmation_required 区分,与 validation 共享 rc=3。信封侧
// internal/output exitCodeForErrorInfo 的「subtype 优先于 type、
// confirmation_required 恒 3」规则与本表同源(轮10裁决⑬;远期独立码
// 保留于规划 OQ-9,落地前不得双轨)。
func (e *Error) ExitCode() int {
switch e.Category {
case CategoryAPI:
@@ -93,6 +135,10 @@ func (e *Error) ExitCode() int {
return 3
case CategoryDiscovery:
return 6
case CategoryPartial:
// An error has no per-item succeeded/failed/unknown data and therefore
// cannot truthfully represent partial_failure. Fail closed as internal.
return ExitCodeInternal
default:
return 5
}
@@ -149,6 +195,11 @@ func WithRetryable(retryable bool) Option {
// WithRetryAfterSeconds records the server-recommended delay before a retry.
// A zero delay is meaningful and is therefore preserved; negative values are
// ignored as invalid server guidance.
//
// 本通道只存原值、不钳制(B195/B199,AC-24):服务端给多少存多少,wire 上
// retry_after_seconds 原样透传。transport 侧的 RetryMaxDelay 钳制只作用于
// 重试延迟选择(retryDelayForAttempt),不得回写或截断本字段(B196 草案:
// 钳制上限可配置化后仍须保持「钳制延迟、不钳制透传」双通道分离)。
func WithRetryAfterSeconds(seconds int64) Option {
return func(err *Error) {
if seconds < 0 {
@@ -322,14 +373,17 @@ func ExitCode(err error) int {
return 5
}
// PrintJSON writes a machine-readable JSON error object.
// PrintJSON writes the legacy machine-readable JSON error object.
//
// This wire predates the unified result framework and is intentionally kept
// byte-compatible for commands whose rollout is legacy_only or dual_validate.
// Unified commands publish outcome/type/subtype through internal/output only.
func PrintJSON(w io.Writer, err error) error {
errorPayload := map[string]any{
"code": ExitCode(err),
"category": category(err),
"message": err.Error(),
}
var typed *Error
if stderrors.As(err, &typed) {
if typed.Reason != "" {
@@ -409,7 +463,7 @@ func PrintJSON(w io.Writer, err error) error {
data, marshalErr := marshalErrorJSON(payload, "", " ")
if marshalErr != nil {
_, writeErr := fmt.Fprintf(w, "{\"error\":{\"code\":5,\"category\":\"internal\",\"message\":\"failed to encode error output\"}}\n")
_, writeErr := fmt.Fprintln(w, `{"error":{"code":5,"category":"internal","message":"failed to encode error output"}}`)
return writeErr
}
@@ -556,6 +610,12 @@ func serverGuidance(diag ServerDiagnostics) (string, string) {
return friendlyHint, actionURL
}
// ServerGuidance exposes the same recovery projection to repository-local
// adapters so legacy JSON and unified-result errors stay semantically aligned.
func ServerGuidance(diag ServerDiagnostics) (string, string) {
return serverGuidance(diag)
}
func safeServerActionURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
@@ -572,6 +632,9 @@ func safeServerActionURL(raw string) string {
func category(err error) string {
var typed *Error
if stderrors.As(err, &typed) {
if typed.Category == CategoryPartial {
return string(CategoryInternal)
}
return string(typed.Category)
}
return string(CategoryInternal)
+60
View File
@@ -0,0 +1,60 @@
// 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 errors
import "testing"
// TestErrorsExitCodeMapConsistentWithExitCode 是 B209 的 errors 侧同源锁定:
// exitcodes.go 的 exitCodeByCategory 映射表必须与 ExitCode() 的 switch 分支
// 逐类别一致(同源不双轨,契约 §4)。任一单边修改即失败,防止未来漂移。
// output 侧同源锁定由 internal/output emitter_phase_c_test.go
// TestExitCodeForEnvelopeSameSourceAsErrorsExitCode 交叉断言(B209)。
func TestErrorsExitCodeMapConsistentWithExitCode(t *testing.T) {
t.Parallel()
cats := []Category{
CategoryAPI,
CategoryAuth,
CategoryValidation,
CategoryDiscovery,
CategoryInternal,
CategoryPartial,
}
for _, cat := range cats {
table := exitCodeByCategory[cat]
viaSwitch := (&Error{Category: cat, Message: "x"}).ExitCode()
if table != viaSwitch {
t.Fatalf("exitCodeByCategory[%q]=%d disagrees with ExitCode()=%d", cat, table, viaSwitch)
}
}
}
// TestErrorsExitCodeConstantsEqualMap 锁定类别专属常量与映射表值一致。
func TestErrorsExitCodeConstantsEqualMap(t *testing.T) {
t.Parallel()
want := map[Category]int{
CategoryAPI: ExitCodeAPI,
CategoryAuth: ExitCodeAuth,
CategoryValidation: ExitCodeValidation,
CategoryDiscovery: ExitCodeDiscovery,
CategoryInternal: ExitCodeInternal,
CategoryPartial: ExitCodeInternal,
}
for cat, wantCode := range want {
if got := exitCodeByCategory[cat]; got != wantCode {
t.Fatalf("exitCodeByCategory[%q]=%d, want %d", cat, got, wantCode)
}
}
}
+442
View File
@@ -0,0 +1,442 @@
// 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 errors
import (
"strings"
"testing"
"time"
)
// TestErrorsPrintJSONFieldInventory protects the published legacy error wire.
// Unified type/subtype/outcome fields belong to internal/output and must not
// leak into commands that have not migrated.
func TestErrorsPrintJSONFieldInventory(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewAPI(
"too many requests",
WithReason("rate_limit"),
WithHint("wait and retry"),
WithRetryable(true),
WithRetryAfterSeconds(30),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
for _, want := range []string{
`"category": "api"`,
`"reason": "rate_limit"`,
`"code": 1`,
`"retryable": true`,
`"retry_after_seconds": 30`,
} {
if !strings.Contains(got, want) {
t.Errorf("missing wire-stable field %s in %s", want, got)
}
}
// informational 组
for _, want := range []string{
`"message": "too many requests"`,
`"hint": "wait and retry"`,
} {
if !strings.Contains(got, want) {
t.Errorf("missing informational field %s in %s", want, got)
}
}
for _, forbidden := range []string{`"outcome"`, `"type"`, `"subtype"`} {
if strings.Contains(got, forbidden) {
t.Errorf("unified field %s leaked into legacy wire: %s", forbidden, got)
}
}
}
func TestErrorsPrintJSONLegacyWireGolden(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewValidation("missing", WithReason("missing_required_flags"))); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
want := "{\n \"error\": {\n \"category\": \"validation\",\n \"code\": 3,\n \"message\": \"missing\",\n \"reason\": \"missing_required_flags\"\n }\n}\n"
if got := b.String(); got != want {
t.Fatalf("legacy error wire changed\n got: %q\nwant: %q", got, want)
}
}
func TestErrorsPrintJSONReasonProjectionStaysLegacy(t *testing.T) {
t.Parallel()
t.Run("confirmation_required", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewValidation(
"confirmation required",
WithReason("confirmation_required"),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
if !strings.Contains(got, `"reason": "confirmation_required"`) {
t.Fatalf("expected confirmation_required reason, got %q", got)
}
if strings.Contains(got, `"subtype"`) || strings.Contains(got, `"type"`) {
t.Fatalf("unified fields leaked into legacy wire: %q", got)
}
})
t.Run("no reason omits reason", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewAPI("plain")); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if strings.Contains(b.String(), `"reason"`) {
t.Fatalf("reason must be omitted when Reason is empty, got %q", b.String())
}
})
}
// TestErrorsConfirmationSharesValidationExitCode 是 B171 的契约断言:
// confirmation_required 是 validation 的子类,共享 rc=3(AC-13,规划 v1.2
// OQ-1 定案),靠 error.subtype 区分,而非独立退出码。
func TestErrorsConfirmationSharesValidationExitCode(t *testing.T) {
t.Parallel()
confirmation := NewValidation("blocked", WithReason("confirmation_required"))
validation := NewValidation("bad param")
if got := ExitCode(confirmation); got != 3 {
t.Fatalf("confirmation ExitCode = %d, want 3 (shared with validation)", got)
}
if got := ExitCode(validation); got != 3 {
t.Fatalf("validation ExitCode = %d, want 3", got)
}
// Legacy reason distinguishes the confirmation subtype.
var b strings.Builder
if err := PrintJSON(&b, confirmation); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if !strings.Contains(b.String(), `"reason": "confirmation_required"`) {
t.Fatalf("confirmation must carry reason, got %q", b.String())
}
}
func TestErrorsPartialCategoryFailsClosedAsInternal(t *testing.T) {
t.Parallel()
err := &Error{Category: CategoryPartial, Message: "partial"}
if got := ExitCode(err); got != ExitCodeInternal {
t.Fatalf("ExitCode(partial error) = %d, want internal %d", got, ExitCodeInternal)
}
if ExitCodePartial != 7 {
t.Fatalf("ExitCodePartial = %d, want 7", ExitCodePartial)
}
var b strings.Builder
if err := PrintJSON(&b, err); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if !strings.Contains(b.String(), `"code": 5`) || !strings.Contains(b.String(), `"category": "internal"`) {
t.Fatalf("partial error must not masquerade as a partial result: %q", b.String())
}
}
func TestErrorsPrintJSONKeepsLegacyCategories(t *testing.T) {
t.Parallel()
cases := []struct {
name string
err error
want string
}{
{"api", NewAPI("x"), "api"},
{"auth", NewAuth("x"), "auth"},
{"validation", NewValidation("x"), "validation"},
{"discovery", NewDiscovery("x"), "discovery"},
{"internal", NewInternal("x"), "internal"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, tc.err); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
if !strings.Contains(got, `"category": "`+tc.want+`"`) {
t.Fatalf("expected legacy category %q in %s", tc.want, got)
}
if strings.Contains(got, `"type"`) {
t.Fatalf("unified type leaked into legacy JSON: %s", got)
}
})
}
}
// TestErrorsWireStableFieldsSubset protects the legacy recovery fields that
// remain useful without changing the top-level envelope.
func TestErrorsWireStableFieldsSubset(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewAPI(
"rpc failed",
WithReason("rate_limit"),
WithHint("h"),
WithRetryable(true),
WithRetryAfterSeconds(5),
WithActions("retry"),
WithServerDiag(ServerDiagnostics{TraceID: "t-1"}),
WithRPCCode(-32602),
WithRPCData([]byte(`{"field":"x"}`)),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
for _, want := range []string{
`"category"`, `"reason"`, `"code"`, `"retryable"`, `"retry_after_seconds"`,
`"message"`, `"hint"`, `"actions"`, `"trace_id"`, `"rpc_code"`, `"rpc_data"`,
} {
if !strings.Contains(got, want) {
t.Errorf("wire-stable field %s missing from %s", want, got)
}
}
}
// TestErrorsRetryableOmitEmpty 是 B175 的断言:retryable 仅 true 时出现在 wire
// (与 output 侧 ErrorInfo.Retryable omitempty 一致);未知三态(RetryableSet
// 未置)时 retryable 缺席。
func TestErrorsRetryableOmitEmpty(t *testing.T) {
t.Parallel()
t.Run("true present", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewAPI("x", WithRetryable(true))); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if !strings.Contains(b.String(), `"retryable": true`) {
t.Fatalf("expected retryable:true, got %q", b.String())
}
})
t.Run("unset omitted", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewAPI("x")); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if strings.Contains(b.String(), `"retryable"`) {
t.Fatalf("unknown retryability must be omitted, got %q", b.String())
}
})
}
// TestErrorsCategorySnapshots 是 B176/B177 的类别错误信封快照测试:api/auth/
// validation(B176)与 discovery/internal/plain(B177)各自产出 stable 的
// type/code 组合,plain 错误归 internal(rc=5)。
func TestErrorsCategorySnapshots(t *testing.T) {
t.Parallel()
cases := []struct {
name string
err error
category string
code string
}{
{"api", NewAPI("x"), "api", `"code": 1`},
{"auth", NewAuth("x"), "auth", `"code": 2`},
{"validation", NewValidation("x"), "validation", `"code": 3`},
{"discovery", NewDiscovery("x"), "discovery", `"code": 6`},
{"internal", NewInternal("x"), "internal", `"code": 5`},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, tc.err); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
if !strings.Contains(got, `"category": "`+tc.category+`"`) || !strings.Contains(got, tc.code) {
t.Fatalf("snapshot mismatch for %s: %s", tc.name, got)
}
})
}
}
// TestErrorsActionsArrayPassthrough 是 B178 的 actions 数组透传断言:Actions
// (含 --yes 版本补救命令)原样进 wire,空串条目被过滤。
func TestErrorsActionsArrayPassthrough(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewValidation(
"confirm required",
WithReason("confirmation_required"),
WithActions("dws chat send --yes", "", "dws chat cancel"),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
for _, want := range []string{
`"actions"`,
`"dws chat send --yes"`,
`"dws chat cancel"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("expected %s in actions, got %q", want, got)
}
}
// 空串条目被过滤:不应出现空引号动作。
if strings.Contains(got, `""`) {
t.Fatalf("empty action must be filtered, got %q", got)
}
}
// TestErrorsTraceRPCAndServerDiagPassthrough 是 B179 的透传保留断言:
// trace_id/rpc_code/rpc_data 原样保留在 wire(informational,不进分支字段),
// 与 output 侧 ErrorInfo 的 ServerDiag/RPC 字段对齐。
func TestErrorsTraceRPCAndServerDiagPassthrough(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewAPI(
"rpc failed",
WithServerDiag(ServerDiagnostics{TraceID: "trace-abc"}),
WithRPCCode(-32602),
WithRPCData([]byte(`{"field":"base_id"}`)),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
for _, want := range []string{
`"trace_id": "trace-abc"`,
`"rpc_code": -32602`,
`"field"`,
`"base_id"`,
} {
if !strings.Contains(got, want) {
t.Errorf("expected %s in %s", want, got)
}
}
}
func TestErrorsLegacyPrintJSONOmitsUnifiedOutcome(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewInternal("x")); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if strings.Contains(b.String(), `"outcome"`) {
t.Fatalf("legacy error JSON must not carry unified outcome, got %q", b.String())
}
}
// TestWithRetryAfterSecondsPassthrough 是 B195 的透传断言:WithRetryAfterSeconds
// 把服务端给出的秒数原样存入 RetryAfterSeconds,不做任何钳制(钳制只作用于
// transport 重试延迟选择,B196 双通道分离)。
func TestWithRetryAfterSecondsPassthrough(t *testing.T) {
t.Parallel()
err := NewAPI("limit", WithRetryAfterSeconds(900)).(*Error)
if err.RetryAfterSeconds == nil || *err.RetryAfterSeconds != 900 {
t.Fatalf("RetryAfterSeconds = %v, want 900 (unclamped)", err.RetryAfterSeconds)
}
}
// TestRetryAfterZeroValuePreserved 是 B198 的零值语义断言:0 秒是有意义的
// 服务端建议(立即重试),必须保留;负值被视为非法服务端指引而被拒绝
// (WithRetryAfterSeconds 忽略负值)。
func TestRetryAfterZeroValuePreserved(t *testing.T) {
t.Parallel()
zero := NewAPI("x", WithRetryAfterSeconds(0)).(*Error)
if zero.RetryAfterSeconds == nil || *zero.RetryAfterSeconds != 0 {
t.Fatalf("zero RetryAfterSeconds must be preserved, got %v", zero.RetryAfterSeconds)
}
negative := NewAPI("x", WithRetryAfterSeconds(-1)).(*Error)
if negative.RetryAfterSeconds != nil {
t.Fatalf("negative RetryAfterSeconds must be rejected, got %v", *negative.RetryAfterSeconds)
}
}
// TestRetryAfterSecondsWirePassthrough 是 B199 的 wire 透传断言:retry_after_seconds
// 在 PrintJSON 错误 JSON 中原样出现,值未被 transport 钳制改写(0 秒也透传)。
func TestRetryAfterSecondsWirePassthrough(t *testing.T) {
t.Parallel()
t.Run("nonzero", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewAPI("x", WithRetryAfterSeconds(60))); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if !strings.Contains(b.String(), `"retry_after_seconds": 60`) {
t.Fatalf("expected retry_after_seconds:60 in wire, got %q", b.String())
}
})
t.Run("zero preserved", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewAPI("x", WithRetryAfterSeconds(0))); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if !strings.Contains(b.String(), `"retry_after_seconds": 0`) {
t.Fatalf("expected retry_after_seconds:0 preserved in wire, got %q", b.String())
}
})
t.Run("unset omitted", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewAPI("x")); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if strings.Contains(b.String(), `"retry_after_seconds"`) {
t.Fatalf("retry_after_seconds must be omitted when unset, got %q", b.String())
}
})
}
// TestRetryAfterSecondsAndNextRetryAtConsistency 是 B200 的一致性断言:
// RetryAfterSeconds 与 NextRetryAt 两字段同源(都描述"何时可重试")且可共存
// 不互斥;Promise 使用 UTC 归一化(NextRetryAt 转 UTC)。
func TestRetryAfterSecondsAndNextRetryAtConsistency(t *testing.T) {
t.Parallel()
tz := time.FixedZone("CST", 8*60*60)
next := time.Date(2026, time.August, 7, 22, 0, 0, 0, tz)
err := NewAPI("x", WithRetryAfterSeconds(30), WithNextRetryAt(next)).(*Error)
if err.RetryAfterSeconds == nil || *err.RetryAfterSeconds != 30 {
t.Fatalf("RetryAfterSeconds = %v, want 30", err.RetryAfterSeconds)
}
if err.NextRetryAt == nil {
t.Fatal("NextRetryAt must be set")
}
// 两字段同源并存(B200),NextRetryAt 归一化为 UTC。
if got := err.NextRetryAt.UTC().Format(time.RFC3339); got != "2026-08-07T14:00:00Z" {
t.Fatalf("NextRetryAt UTC = %s, want 2026-08-07T14:00:00Z", got)
}
// wire 上两字段同现且互不覆盖。
var b strings.Builder
if err := PrintJSON(&b, err); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
if !strings.Contains(got, `"retry_after_seconds": 30`) {
t.Fatalf("missing retry_after_seconds:30 in %s", got)
}
if !strings.Contains(got, `"next_retry_at": "2026-08-07T14:00:00Z"`) {
t.Fatalf("missing next_retry_at in %s", got)
}
}
+12
View File
@@ -341,6 +341,18 @@ func TestCrossPlatformCoveragePrintHumanIncludesServerGuidance(t *testing.T) {
}
}
func TestCrossPlatformCoverageServerGuidanceAdapter(t *testing.T) {
t.Parallel()
hint, action := ServerGuidance(ServerDiagnostics{
FriendlyHint: "follow the recovery action",
ActionURL: "https://example.test/recover",
})
if hint != "follow the recovery action" || action != "https://example.test/recover" {
t.Fatalf("ServerGuidance() = (%q, %q)", hint, action)
}
}
func TestCrossPlatformCoverageServerGuidanceSuppressesUnsafeActionURL(t *testing.T) {
t.Parallel()
for _, actionURL := range []string{
+18
View File
@@ -0,0 +1,18 @@
package errors
// 统一退出码表(B171/B172;轮 10 裁决⑬,权威 = 规划 v1.2 OQ-1 定案):
// 类别与退出码一一对应,`confirmation_required` 是 `validation` 下的子类
// 共享 3,不新增独立退出码。
//
// 跨包同源锁定:internal/output.ExitCodeForEnvelope 对同一信封必须给出
// 本表完全一致的码(api=1/auth=2/validation=3/discovery=6/internal=5)。
// Typed output.Partial remains the only path to partial_failure exit 7.
// 修改本表 = 契约变更,必须双侧同步并更新两侧同源测试。
var exitCodeByCategory = map[Category]int{
CategoryAPI: ExitCodeAPI,
CategoryAuth: ExitCodeAuth,
CategoryValidation: ExitCodeValidation,
CategoryDiscovery: ExitCodeDiscovery,
CategoryInternal: ExitCodeInternal,
CategoryPartial: ExitCodeInternal,
}
+41
View File
@@ -0,0 +1,41 @@
package errors
// 错误信封 wire-stable 字段集(契约规范 §2.4;B174)。
//
// Agent 可编程分流的字段集合:type/subtype/code/retryable/
// retry_after_seconds/message/hint/actions/trace_id/rpc_code/rpc_data/
// outcome。wireErrors 的每个字段都必须落在此集合内;新增字段 = 契约
// 扩展,需评审。wireErrors 未声明 JSON tag 的字段(如 Cause)是内部
// 字段,序列化缺席,不属于 wire。
// WireStableFields 是错误信封 wire-stable 字段名全集(含 outcome)。
var WireStableFields = []string{
"type",
"subtype",
"code",
"retryable",
"retry_after_seconds",
"message",
"hint",
"actions",
"trace_id",
"rpc_code",
"rpc_data",
"outcome",
}
// WireStableErrorBodyFields 是 error 对象体(不含顶层 outcome)的
// wire-stable 字段名子集。
var WireStableErrorBodyFields = []string{
"type",
"subtype",
"code",
"retryable",
"retry_after_seconds",
"message",
"hint",
"actions",
"trace_id",
"rpc_code",
"rpc_data",
}
+16
View File
@@ -17,6 +17,7 @@ import (
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
eventlock "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
@@ -324,6 +325,21 @@ func TestCrossPlatformCoverageRunStartupAndSourceEdges(t *testing.T) {
if err := Run(context.Background(), base); !errors.Is(err, errBusInjected) {
t.Fatalf("source error = %v", err)
}
runtimeWorkDir := shortTempDir(t)
base.WorkDir = runtimeWorkDir
base.IPCEndpoint = dwsevent.IPCEndpoint(
runtimeWorkDir,
"open",
dwsevent.SourceKindPersonalStream,
dwsevent.IdentityHash(runtimeWorkDir),
)
base.Source = edgeSource{start: func(context.Context, dwsevent.EmitFn) error {
return runtimecred.ErrRuntimeTokenRejected
}}
if err := Run(context.Background(), base); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
t.Fatalf("runtime source error = %v", err)
}
}
type scriptedListener struct {
@@ -0,0 +1,275 @@
package bus
import (
"context"
"errors"
"io"
"log/slog"
"net"
"runtime"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
func eventCoreDaemon(broker *runtimecred.Broker) *daemon {
return &daemon{
cfg: Config{CredentialBroker: broker, IdleTimeout: time.Second},
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
hub: NewHub(4),
started: time.Now(),
idleStop: make(chan struct{}),
}
}
func eventCoreConnection(d *daemon, wrap func(net.Conn) net.Conn) (net.Conn, *transport.Writer, *transport.Reader, <-chan struct{}) {
server, client := net.Pipe()
if wrap != nil {
server = wrap(server)
}
done := make(chan struct{})
go func() {
d.handleConnection(context.Background(), server)
close(done)
}()
return client, transport.NewWriter(client), transport.NewReader(client), done
}
func eventCoreWaitDone(t *testing.T, done <-chan struct{}) {
t.Helper()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("connection handler did not stop")
}
}
type eventCoreWriteHookConn struct {
net.Conn
writes int
hook func(int)
}
func (c *eventCoreWriteHookConn) Write(p []byte) (int, error) {
n, err := c.Conn.Write(p)
c.writes++
if err == nil && c.hook != nil {
c.hook(c.writes)
}
return n, err
}
func TestCrossPlatformCoverageEventCoreDaemonHandshakeEdges(t *testing.T) {
t.Run("incompatible ack write failure", func(t *testing.T) {
d := eventCoreDaemon(nil)
client, w, _, done := eventCoreConnection(d, nil)
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: "unsupported"}); err != nil {
t.Fatal(err)
}
_ = client.Close()
eventCoreWaitDone(t, done)
})
t.Run("runtime ack write failure", func(t *testing.T) {
d := eventCoreDaemon(runtimecred.New(runtimecred.Config{}))
client, w, _, done := eventCoreConnection(d, nil)
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: transport.CredentialModeRuntimeToken}); err != nil {
t.Fatal(err)
}
_ = client.Close()
eventCoreWaitDone(t, done)
})
t.Run("terminal runtime hello", func(t *testing.T) {
d := eventCoreDaemon(runtimecred.New(runtimecred.Config{}))
d.setTerminalReason(transport.ByeReasonRuntimeTokenRejected)
client, w, r, done := eventCoreConnection(d, nil)
defer client.Close()
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: transport.CredentialModeRuntimeToken}); err != nil {
t.Fatal(err)
}
var ack transport.HelloAck
if err := r.ReadJSON(&ack); err != nil || ack.TerminalReason != transport.ByeReasonRuntimeTokenRejected {
t.Fatalf("terminal ack = %#v, %v", ack, err)
}
eventCoreWaitDone(t, done)
})
t.Run("malformed credential update", func(t *testing.T) {
d := eventCoreDaemon(runtimecred.New(runtimecred.Config{}))
client, w, r, done := eventCoreConnection(d, nil)
defer client.Close()
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: transport.CredentialModeRuntimeToken}); err != nil {
t.Fatal(err)
}
var ack transport.HelloAck
if err := r.ReadJSON(&ack); err != nil {
t.Fatal(err)
}
if _, err := client.Write([]byte("{\n")); err != nil {
t.Fatal(err)
}
eventCoreWaitDone(t, done)
})
t.Run("unexpected credential update", func(t *testing.T) {
d := eventCoreDaemon(runtimecred.New(runtimecred.Config{}))
client, w, r, done := eventCoreConnection(d, nil)
defer client.Close()
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: transport.CredentialModeRuntimeToken}); err != nil {
t.Fatal(err)
}
var ack transport.HelloAck
if err := r.ReadJSON(&ack); err != nil {
t.Fatal(err)
}
if err := w.WriteJSON(transport.Heartbeat{Type: transport.FrameTypeHeartbeat}); err != nil {
t.Fatal(err)
}
var updateAck transport.CredentialUpdateAck
if err := r.ReadJSON(&updateAck); err != nil || updateAck.ErrorCode != transport.CredentialErrorInvalid {
t.Fatalf("unexpected-frame ack = %#v, %v", updateAck, err)
}
eventCoreWaitDone(t, done)
})
t.Run("credential ack write failure", func(t *testing.T) {
d := eventCoreDaemon(runtimecred.New(runtimecred.Config{}))
client, w, r, done := eventCoreConnection(d, nil)
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: transport.CredentialModeRuntimeToken}); err != nil {
t.Fatal(err)
}
var ack transport.HelloAck
if err := r.ReadJSON(&ack); err != nil {
t.Fatal(err)
}
if err := w.WriteJSON(transport.CredentialUpdate{
Type: transport.FrameTypeCredentialUpdate, ExpectedGeneration: ack.CredentialGeneration, Token: "token",
}); err != nil {
t.Fatal(err)
}
_ = client.Close()
eventCoreWaitDone(t, done)
})
t.Run("activation conflict", func(t *testing.T) {
broker := runtimecred.New(runtimecred.Config{RequireSeed: true, RequireActivation: true})
d := eventCoreDaemon(broker)
client, w, r, done := eventCoreConnection(d, func(conn net.Conn) net.Conn {
return &eventCoreWriteHookConn{Conn: conn, hook: func(write int) {
if write == 2 {
_, _ = broker.Update(1, "newer-token")
}
}}
})
defer client.Close()
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: transport.CredentialModeRuntimeToken}); err != nil {
t.Fatal(err)
}
var ack transport.HelloAck
if err := r.ReadJSON(&ack); err != nil {
t.Fatal(err)
}
if err := w.WriteJSON(transport.CredentialUpdate{Type: transport.FrameTypeCredentialUpdate, Token: "first-token"}); err != nil {
t.Fatal(err)
}
var updateAck transport.CredentialUpdateAck
if err := r.ReadJSON(&updateAck); err != nil || !updateAck.Accepted {
t.Fatalf("credential ack = %#v, %v", updateAck, err)
}
var bye transport.Bye
if err := r.ReadJSON(&bye); err != nil || bye.Reason != "runtime_credential_activation_failed" {
t.Fatalf("activation failure bye = %#v, %v", bye, err)
}
eventCoreWaitDone(t, done)
})
}
func TestCrossPlatformCoverageEventCoreDaemonWriterStopEdges(t *testing.T) {
originalProcs := runtime.GOMAXPROCS(1)
t.Cleanup(func() { runtime.GOMAXPROCS(originalProcs) })
run := func(t *testing.T, queueEvent bool) {
t.Helper()
d := eventCoreDaemon(nil)
client, w, r, done := eventCoreConnection(d, nil)
defer client.Close()
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, SubscribeID: "writer-stop"}); err != nil {
t.Fatal(err)
}
var ack transport.HelloAck
if err := r.ReadJSON(&ack); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(time.Second)
for d.hub.Len() != 1 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
time.Sleep(5 * time.Millisecond)
d.hub.mu.RLock()
var consumer *Consumer
for _, candidate := range d.hub.consumers {
consumer = candidate
}
d.hub.mu.RUnlock()
if consumer == nil {
t.Fatal("consumer not registered")
}
if queueEvent {
consumer.SendCh <- transport.Heartbeat{Type: transport.FrameTypeHeartbeat}
}
consumer.StopCh <- "writer-stop"
var bye transport.Bye
if err := r.ReadJSON(&bye); err != nil || bye.Reason != "writer-stop" {
t.Fatalf("writer stop bye = %#v, %v", bye, err)
}
eventCoreWaitDone(t, done)
}
t.Run("recheck after event", func(t *testing.T) { run(t, true) })
t.Run("blocked stop select", func(t *testing.T) { run(t, false) })
}
func TestCrossPlatformCoverageEventCoreDaemonHelpersAndStopAll(t *testing.T) {
var nilDaemon *daemon
nilDaemon.setTerminalReason("ignored")
if nilDaemon.getTerminalReason() != "" {
t.Fatal("nil daemon returned terminal reason")
}
d := eventCoreDaemon(nil)
d.setTerminalReason("ignored")
if d.getTerminalReason() != "" {
t.Fatal("invalid terminal reason was stored")
}
d.setTerminalReason(transport.ByeReasonRuntimeTokenRejected)
if d.getTerminalReason() != transport.ByeReasonRuntimeTokenRejected {
t.Fatal("terminal reason was not stored")
}
if code, _ := classifyCredentialUpdateError(runtimecred.ErrEmptyToken); code != transport.CredentialErrorInvalid {
t.Fatalf("empty-token classification = %q", code)
}
if code, message := classifyCredentialUpdateError(errors.New("internal detail")); code != transport.CredentialErrorInternal || message != "runtime credential update failed" {
t.Fatalf("internal classification = %q, %q", code, message)
}
hub := NewHub(1)
consumer, err := hub.Register(transport.Hello{})
if err != nil {
t.Fatal(err)
}
if stopped := hub.StopAll(" "); stopped != 1 {
t.Fatalf("StopAll = %d", stopped)
}
select {
case reason := <-consumer.StopCh:
if reason != "shutdown" {
t.Fatalf("default stop reason = %q", reason)
}
case <-time.After(time.Second):
t.Fatal("default stop reason not delivered")
}
hub.Unregister(consumer.ID)
}
+251 -35
View File
@@ -30,6 +30,7 @@ import (
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/dedup"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
@@ -78,6 +79,11 @@ type Config struct {
// Source is the cloud adapter. Required.
Source SourceAdapter
// CredentialBroker enables additive runtime-token handoff over the
// owner-only local IPC transport. Nil preserves the original protocol and
// does not advertise runtime-token support.
CredentialBroker *runtimecred.Broker
// IdleTimeout: bus self-exits after this long with zero consumers.
// Zero disables (bus runs until SIGTERM).
IdleTimeout time.Duration
@@ -106,11 +112,13 @@ type Config struct {
}
var (
daemonMkdirAll = os.MkdirAll
daemonAcquire = Acquire
daemonWriteMeta = WriteMeta
daemonListen = transport.Listen
daemonShutdownTimeout = 2 * time.Second
daemonMkdirAll = os.MkdirAll
daemonAcquire = Acquire
daemonWriteMeta = WriteMeta
daemonListen = transport.Listen
daemonShutdownTimeout = 2 * time.Second
daemonByeDrainTimeout = 100 * time.Millisecond
daemonCredentialHandshakeTimeout = 10 * time.Second
)
// Run starts the bus daemon. Lifecycle (plan §4 invariant #6):
@@ -237,12 +245,22 @@ func Run(ctx context.Context, cfg Config) error {
// 6. Wait for shutdown trigger.
var exitErr error
shutdownReason := "shutdown"
select {
case <-ctx.Done():
log.Info("bus: shutdown requested by ctx", "reason", ctx.Err())
case err := <-srcErr:
log.Error("bus: source exited", "err", err)
exitErr = err
shutdownReason = sourceShutdownReason(err)
if shutdownReason == transport.ByeReasonRuntimeTokenRejected {
// A runtime token can fail immediately after Broker.Update. Serialize
// terminal publication with that handshake so the initiating consumer
// is registered (or receives a terminal HelloAck) before shutdown.
d.credentialHandoffMu.Lock()
d.setTerminalReason(shutdownReason)
d.credentialHandoffMu.Unlock()
}
case <-d.idleStop:
log.Info("bus: idle timeout reached, shutting down")
}
@@ -252,7 +270,7 @@ func Run(ctx context.Context, cfg Config) error {
// consumers. The accept-loop barrier is required before WaitGroup.Wait:
// sync.WaitGroup forbids a positive Add racing with Wait.
cancelRun()
d.shutdown(acceptDone)
d.shutdown(acceptDone, shutdownReason)
<-idleDone
<-dropWarnDone
@@ -274,6 +292,10 @@ type daemon struct {
shutdownMu sync.Mutex
shuttingDown atomic.Bool
idleStop chan struct{}
credentialHandoffMu sync.Mutex
terminalMu sync.RWMutex
terminalReason string
}
// closeOnceConn makes every connection close path idempotent. A live consumer
@@ -330,7 +352,8 @@ func (d *daemon) acceptLoop(ctx context.Context) {
}
// handleConnection processes one IPC connection's full lifecycle: read
// Hello → register with Hub → spawn writer goroutine → read until EOF/Bye.
// Hello → optional runtime credential negotiation → register with Hub → spawn
// writer goroutine → read until EOF/Bye.
// Always Unregisters and Closes on exit (plan invariant #5).
func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
conn = ensureCloseOnce(conn)
@@ -372,29 +395,141 @@ func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
return
}
// Regular consumer registration
// HelloAck — credentials_source fields are filled in by the daemon
// runner (which knows from the strict resolver) and exposed via the
// adapter for forward-compat. v1 leaves them empty here; daemon.Run
// passes them through future config if the caller wishes.
ack := d.helloAck()
handoffLocked := false
runtimeGeneration := uint64(0)
defer func() {
if handoffLocked {
d.credentialHandoffMu.Unlock()
}
}()
// Runtime credentials use a two-phase additive handshake. The first ack
// proves capability before the client sends any secret. Only a successful
// CAS and credential ack permit Hub registration.
if hello.CredentialMode != "" {
if hello.CredentialMode != transport.CredentialModeRuntimeToken || d.cfg.CredentialBroker == nil {
ack.Capabilities = nil
ack.CredentialGeneration = 0
if err := w.WriteJSON(ack); err != nil {
d.log.Warn("bus: incompatible helloack write failed", "err", err)
}
return
}
d.credentialHandoffMu.Lock()
handoffLocked = true
// Terminal state may have been published while this Hello waited for a
// concurrent credential handoff. Rebuild the ack while holding the gate.
ack = d.helloAck()
if err := w.WriteJSON(ack); err != nil {
d.log.Warn("bus: runtime helloack write failed", "err", err)
return
}
if ack.TerminalReason == transport.ByeReasonRuntimeTokenRejected {
return
}
var update transport.CredentialUpdate
_ = conn.SetReadDeadline(time.Now().Add(daemonCredentialHandshakeTimeout))
if err := r.ReadJSON(&update); err != nil {
// Do not include the decoder error: malformed JSON may contain
// fragments of the credential.
d.log.Warn("bus: malformed runtime credential update")
return
}
_ = conn.SetReadDeadline(time.Time{})
if update.Type != transport.FrameTypeCredentialUpdate {
_ = w.WriteJSON(transport.CredentialUpdateAck{
Type: transport.FrameTypeCredentialUpdateAck,
Accepted: false,
CredentialGeneration: d.cfg.CredentialBroker.Generation(),
ErrorCode: transport.CredentialErrorInvalid,
Error: "unexpected credential update frame",
})
return
}
// Validate registration before applying the credential or sending an
// accepted ack. Hub.Register performs the same deterministic compile
// before mutating the Hub; this preflight keeps invalid filters from
// producing a ready marker after credential negotiation.
if _, err := compileMatcher(hello.EventTypes, hello.Filter, hello.SubscribeID); err != nil {
update.Token = ""
_ = w.WriteJSON(transport.CredentialUpdateAck{
Type: transport.FrameTypeCredentialUpdateAck,
Accepted: false,
CredentialGeneration: d.cfg.CredentialBroker.Generation(),
ErrorCode: transport.CredentialErrorRegistration,
Error: "consumer registration validation failed",
})
d.log.Warn("bus: runtime consumer registration validation failed")
return
}
generation, updateErr := d.cfg.CredentialBroker.Update(update.ExpectedGeneration, update.Token)
runtimeGeneration = generation
update.Token = ""
credentialAck := transport.CredentialUpdateAck{
Type: transport.FrameTypeCredentialUpdateAck,
Accepted: updateErr == nil,
CredentialGeneration: generation,
}
if updateErr != nil {
credentialAck.ErrorCode, credentialAck.Error = classifyCredentialUpdateError(updateErr)
}
if err := w.WriteJSON(credentialAck); err != nil {
d.log.Warn("bus: credential update ack write failed", "err", err)
return
}
if updateErr != nil {
d.log.Warn("bus: runtime credential update rejected", "error_code", credentialAck.ErrorCode)
return
}
}
// Regular consumer registration. Local clients retain the original
// register-before-HelloAck ordering; runtime clients were already acked by
// the additive handshake above.
c, err := d.hub.Register(hello)
if err != nil {
d.log.Warn("bus: register failed", "err", err, "pid", hello.ConsumerPID)
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "register_failed: " + err.Error()})
return
}
// HelloAck — credentials_source fields are filled in by the daemon
// runner (which knows from the strict resolver) and exposed via the
// adapter for forward-compat. v1 leaves them empty here; daemon.Run
// passes them through future config if the caller wishes.
idleSecs := int(d.cfg.IdleTimeout / time.Second)
if err := w.WriteJSON(transport.HelloAck{
Type: transport.FrameTypeHelloAck,
BusPID: os.Getpid(),
SourceState: "connected", // best-effort; full state machine pushed via SourceState frames
StateSource: "inferred",
IdleTimeoutSecs: idleSecs,
}); err != nil {
d.log.Warn("bus: helloack write failed", "err", err)
if handoffLocked {
// The runtime broker deliberately keeps the seed pending until the
// initiating consumer is registered. This prevents ticket acquisition
// (and an immediate 401) from racing ahead of the only connection that
// can observe the typed terminal reason.
if _, activateErr := d.cfg.CredentialBroker.Activate(runtimeGeneration); activateErr != nil {
d.log.Error("bus: runtime credential activation failed")
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "runtime_credential_activation_failed"})
d.hub.Unregister(c.ID)
return
}
}
// A local/legacy consumer can arrive after terminal publication but after
// StopAll took its snapshot. Refuse it synchronously so it cannot observe a
// clean EOF for a runtime-token rejection.
if terminalReason := d.getTerminalReason(); terminalReason != "" {
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: terminalReason})
d.hub.Unregister(c.ID)
return
}
if handoffLocked {
d.credentialHandoffMu.Unlock()
handoffLocked = false
}
if hello.CredentialMode == "" {
if err := w.WriteJSON(ack); err != nil {
d.log.Warn("bus: helloack write failed", "err", err)
d.hub.Unregister(c.ID)
return
}
}
// Writer goroutine pulls from SendCh and writes to the wire.
writerDone := make(chan struct{})
@@ -416,6 +551,16 @@ func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
if !ok {
return
}
// A stop may have arrived while both channels were ready and the
// scheduler selected the buffered event. Re-check before starting a
// potentially blocking event write so terminal reasons stay prompt.
select {
case reason := <-c.StopCh:
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: reason})
_ = conn.Close()
return
default:
}
if err := w.WriteJSON(frame); err != nil {
return
}
@@ -454,6 +599,51 @@ func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
_ = ctx // for future use (writer ctx-cancel propagation)
}
func (d *daemon) helloAck() transport.HelloAck {
ack := transport.HelloAck{
Type: transport.FrameTypeHelloAck,
BusPID: os.Getpid(),
SourceState: "connected", // best-effort; full state machine pushed via SourceState frames
StateSource: "inferred",
IdleTimeoutSecs: int(d.cfg.IdleTimeout / time.Second),
}
if d.cfg.CredentialBroker != nil {
ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
ack.CredentialGeneration = d.cfg.CredentialBroker.Generation()
}
ack.TerminalReason = d.getTerminalReason()
return ack
}
func (d *daemon) setTerminalReason(reason string) {
if d == nil || reason != transport.ByeReasonRuntimeTokenRejected {
return
}
d.terminalMu.Lock()
d.terminalReason = reason
d.terminalMu.Unlock()
}
func (d *daemon) getTerminalReason() string {
if d == nil {
return ""
}
d.terminalMu.RLock()
defer d.terminalMu.RUnlock()
return d.terminalReason
}
func classifyCredentialUpdateError(err error) (string, string) {
var conflict *runtimecred.GenerationConflictError
if errors.As(err, &conflict) {
return transport.CredentialErrorGenerationConflict, conflict.Error()
}
if errors.Is(err, runtimecred.ErrEmptyToken) || errors.Is(err, runtimecred.ErrTokenTooLarge) {
return transport.CredentialErrorInvalid, err.Error()
}
return transport.CredentialErrorInternal, "runtime credential update failed"
}
func (d *daemon) handleConsumerStopRPC(w *transport.Writer, r *transport.Reader) {
var req transport.ConsumerStopReq
if err := r.ReadJSON(&req); err != nil {
@@ -577,38 +767,64 @@ func (d *daemon) triggerShutdown(reason string) {
// 4. wait for acceptLoop to return so no future consumerWG.Add can occur
// 5. close all accepted connections and wait for handlers to drain
// 6. lock + meta cleanup via Run's defers
func (d *daemon) shutdown(acceptDone <-chan struct{}) {
func (d *daemon) shutdown(acceptDone <-chan struct{}, reasons ...string) {
d.shutdownMu.Lock()
defer d.shutdownMu.Unlock()
if !d.shuttingDown.CompareAndSwap(false, true) {
return
}
d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"})
reason := normalizedShutdownReason(reasons...)
if reason == transport.ByeReasonRuntimeTokenRejected {
d.hub.StopAll(reason)
} else {
d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: reason})
}
_ = d.listener.Close()
<-acceptDone
// Force-close all open IPC connections so any reader goroutine blocked
// on Read() returns with a network error and exits cleanly. Without
// this the consumerWG never drains and Run hangs forever.
d.conns.Range(func(k, _ any) bool {
if c, ok := k.(net.Conn); ok {
_ = c.Close()
}
return true
})
// Give consumers a brief moment to drain final frames before we tear
// down their channels.
// Let local consumers drain the final Bye before force-closing their
// connections. This short grace period is what makes typed shutdown
// reasons (notably runtime_token_rejected) observable instead of racing
// with EOF. Consumers close their side immediately after reading Bye.
doneCh := make(chan struct{})
go func() {
d.consumerWG.Wait()
close(doneCh)
}()
select {
case <-doneCh:
return
case <-time.After(daemonByeDrainTimeout):
}
// A wedged/old consumer may not close after Bye. Force-close remaining
// connections so the daemon still has a bounded shutdown.
d.conns.Range(func(k, _ any) bool {
if c, ok := k.(net.Conn); ok {
_ = c.Close()
}
return true
})
select {
case <-doneCh:
case <-time.After(daemonShutdownTimeout):
d.log.Warn("bus: shutdown: consumer goroutines did not drain within 2s")
}
}
func sourceShutdownReason(err error) string {
if errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
return transport.ByeReasonRuntimeTokenRejected
}
return "shutdown"
}
func normalizedShutdownReason(reasons ...string) string {
if len(reasons) > 0 && reasons[0] == transport.ByeReasonRuntimeTokenRejected {
return transport.ByeReasonRuntimeTokenRejected
}
return "shutdown"
}
// signalReady writes a single 'R' byte to the ready pipe (if provided) and
// closes it. The parent process (busctl/spawn) reads one byte and proceeds.
func signalReady(p *os.File) {
+25
View File
@@ -246,6 +246,31 @@ func (h *Hub) StopConsumers(subscribeIDs []string, reason string) []string {
return out
}
// StopAll requests a high-priority graceful close for every live consumer.
// Unlike Broadcast(Bye), this uses the writer's priority StopCh and therefore
// cannot sit behind a full event buffer during a terminal source failure.
func (h *Hub) StopAll(reason string) int {
reason = strings.TrimSpace(reason)
if reason == "" {
reason = "shutdown"
}
h.mu.RLock()
consumers := make([]*Consumer, 0, len(h.consumers))
for _, consumer := range h.consumers {
consumers = append(consumers, consumer)
}
h.mu.RUnlock()
stopped := 0
for _, consumer := range consumers {
select {
case consumer.StopCh <- reason:
stopped++
default:
}
}
return stopped
}
// Unregister removes a consumer by ID and closes its sendCh. Idempotent —
// calling twice or on an unknown ID is a no-op. closeSend shares the same
// per-consumer lock as Deliver/Broadcast, so a stale Hub snapshot cannot send
+21
View File
@@ -346,6 +346,27 @@ func TestHub_StopConsumersCoalescesQueuedStop(t *testing.T) {
}
}
func TestCrossPlatformCoverageHubStopAllBypassesFullEventBuffer(t *testing.T) {
hub := NewHub(1)
consumer, err := hub.Register(transport.Hello{Type: transport.FrameTypeHello})
if err != nil {
t.Fatal(err)
}
consumer.SendCh <- transport.Event{Type: transport.FrameTypeEvent}
if stopped := hub.StopAll(transport.ByeReasonRuntimeTokenRejected); stopped != 1 {
t.Fatalf("StopAll() = %d, want 1", stopped)
}
select {
case reason := <-consumer.StopCh:
if reason != transport.ByeReasonRuntimeTokenRejected {
t.Fatalf("StopCh reason = %q", reason)
}
default:
t.Fatal("terminal stop was blocked behind the full event buffer")
}
}
func TestHub_ConcurrentStopConsumersRegisterUnregister(t *testing.T) {
h := NewHub(4)
const workers = 32
@@ -0,0 +1,407 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package bus
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"net"
"path/filepath"
"strings"
"testing"
"time"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
type runtimeCredentialRejectSource struct {
broker *runtimecred.Broker
}
func (s *runtimeCredentialRejectSource) Start(ctx context.Context, _ dwsevent.EmitFn) error {
if _, err := s.broker.Resolve(ctx); err != nil {
return err
}
return runtimecred.ErrRuntimeTokenRejected
}
func runtimeCredentialDaemon(broker *runtimecred.Broker, logOutput io.Writer) *daemon {
if logOutput == nil {
logOutput = io.Discard
}
return &daemon{
cfg: Config{CredentialBroker: broker},
log: slog.New(slog.NewTextHandler(logOutput, nil)),
hub: NewHub(2),
started: time.Now(),
idleStop: make(chan struct{}),
}
}
func runRuntimeCredentialConnection(t *testing.T, d *daemon) (net.Conn, *transport.Writer, *transport.Reader, <-chan struct{}) {
t.Helper()
server, client := net.Pipe()
done := make(chan struct{})
go func() {
d.handleConnection(context.Background(), server)
close(done)
}()
return client, transport.NewWriter(client), transport.NewReader(client), done
}
func TestCrossPlatformCoverageDaemonRuntimeCredentialHandshakeBeforeRegister(t *testing.T) {
broker := runtimecred.New(runtimecred.Config{RequireSeed: true, RequireActivation: true})
d := runtimeCredentialDaemon(broker, nil)
client, w, r, done := runRuntimeCredentialConnection(t, d)
defer client.Close()
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
ConsumerPID: 42,
CredentialMode: transport.CredentialModeRuntimeToken,
}); err != nil {
t.Fatal(err)
}
var helloAck transport.HelloAck
if err := r.ReadJSON(&helloAck); err != nil {
t.Fatal(err)
}
if !hasTransportCapability(helloAck.Capabilities, transport.CapabilityRuntimeTokenV1) || helloAck.CredentialGeneration != 0 {
t.Fatalf("hello ack = %#v", helloAck)
}
if d.hub.Len() != 0 {
t.Fatalf("consumer registered before credential update: %d", d.hub.Len())
}
const canary = "ipc-canary-runtime-token"
if err := w.WriteJSON(transport.CredentialUpdate{
Type: transport.FrameTypeCredentialUpdate,
ExpectedGeneration: helloAck.CredentialGeneration,
Token: canary,
}); err != nil {
t.Fatal(err)
}
var updateAck transport.CredentialUpdateAck
if err := r.ReadJSON(&updateAck); err != nil {
t.Fatal(err)
}
if !updateAck.Accepted || updateAck.CredentialGeneration != 1 {
t.Fatalf("credential ack = %#v", updateAck)
}
deadline := time.Now().Add(time.Second)
for d.hub.Len() != 1 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if d.hub.Len() != 1 {
t.Fatal("consumer was not registered after credential ack")
}
if token, err := broker.Resolve(context.Background()); err != nil || token != canary {
t.Fatalf("broker did not resolve installed runtime token: %v", err)
}
if err := w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "done"}); err != nil {
t.Fatal(err)
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("connection did not close")
}
}
func TestCrossPlatformCoverageDaemonCompatibleBusRotatesRuntimeCredentialAcrossConnections(t *testing.T) {
broker := runtimecred.New(runtimecred.Config{RequireSeed: true, RequireActivation: true})
d := runtimeCredentialDaemon(broker, nil)
handshake := func(token string, wantHelloGeneration, wantAckGeneration uint64) {
t.Helper()
client, w, r, done := runRuntimeCredentialConnection(t, d)
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
CredentialMode: transport.CredentialModeRuntimeToken,
}); err != nil {
t.Fatal(err)
}
var helloAck transport.HelloAck
if err := r.ReadJSON(&helloAck); err != nil {
t.Fatal(err)
}
if helloAck.CredentialGeneration != wantHelloGeneration {
t.Fatalf("hello generation = %d, want %d", helloAck.CredentialGeneration, wantHelloGeneration)
}
if err := w.WriteJSON(transport.CredentialUpdate{
Type: transport.FrameTypeCredentialUpdate,
ExpectedGeneration: helloAck.CredentialGeneration,
Token: token,
}); err != nil {
t.Fatal(err)
}
var updateAck transport.CredentialUpdateAck
if err := r.ReadJSON(&updateAck); err != nil {
t.Fatal(err)
}
if !updateAck.Accepted || updateAck.CredentialGeneration != wantAckGeneration {
t.Fatalf("credential ack accepted=%v generation=%d, want true/%d", updateAck.Accepted, updateAck.CredentialGeneration, wantAckGeneration)
}
if err := w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "done"}); err != nil {
t.Fatal(err)
}
_ = client.Close()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("connection did not close")
}
}
handshake("runtime-token-a", 0, 1)
if token, err := broker.Resolve(context.Background()); err != nil || token != "runtime-token-a" {
t.Fatalf("broker did not retain first token: %v", err)
}
handshake("runtime-token-b", 1, 2)
if token, err := broker.Resolve(context.Background()); err != nil || token != "runtime-token-b" {
t.Fatalf("broker did not rotate to second token: %v", err)
}
}
func TestCrossPlatformCoverageDaemonRuntimeCredentialMissingCapabilityDoesNotRegister(t *testing.T) {
d := runtimeCredentialDaemon(nil, nil)
client, w, r, done := runRuntimeCredentialConnection(t, d)
defer client.Close()
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
CredentialMode: transport.CredentialModeRuntimeToken,
}); err != nil {
t.Fatal(err)
}
var ack transport.HelloAck
if err := r.ReadJSON(&ack); err != nil {
t.Fatal(err)
}
if hasTransportCapability(ack.Capabilities, transport.CapabilityRuntimeTokenV1) {
t.Fatalf("unsupported daemon advertised capability: %#v", ack)
}
if d.hub.Len() != 0 {
t.Fatalf("unsupported daemon registered consumer: %d", d.hub.Len())
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("unsupported connection did not close")
}
}
func TestCrossPlatformCoverageDaemonTerminalStateRejectsLateLegacyConsumer(t *testing.T) {
d := runtimeCredentialDaemon(nil, nil)
d.setTerminalReason(transport.ByeReasonRuntimeTokenRejected)
client, w, r, done := runRuntimeCredentialConnection(t, d)
defer client.Close()
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, ConsumerPID: 7}); err != nil {
t.Fatal(err)
}
var bye transport.Bye
if err := r.ReadJSON(&bye); err != nil {
t.Fatal(err)
}
if bye.Type != transport.FrameTypeBye || bye.Reason != transport.ByeReasonRuntimeTokenRejected {
t.Fatalf("late consumer frame = %#v", bye)
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("late consumer connection did not close")
}
if d.hub.Len() != 0 {
t.Fatalf("late terminal consumer remained registered: %d", d.hub.Len())
}
}
func TestCrossPlatformCoverageDaemonRuntimeCredentialConflictDoesNotLeakOrRegister(t *testing.T) {
broker := runtimecred.New(runtimecred.Config{})
if _, err := broker.Update(0, "installed-secret"); err != nil {
t.Fatal(err)
}
var logs bytes.Buffer
d := runtimeCredentialDaemon(broker, &logs)
client, w, r, done := runRuntimeCredentialConnection(t, d)
defer client.Close()
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
CredentialMode: transport.CredentialModeRuntimeToken,
}); err != nil {
t.Fatal(err)
}
var helloAck transport.HelloAck
if err := r.ReadJSON(&helloAck); err != nil {
t.Fatal(err)
}
const canary = "rejected-canary-secret"
if err := w.WriteJSON(transport.CredentialUpdate{
Type: transport.FrameTypeCredentialUpdate,
ExpectedGeneration: 0,
Token: canary,
}); err != nil {
t.Fatal(err)
}
var updateAck transport.CredentialUpdateAck
if err := r.ReadJSON(&updateAck); err != nil {
t.Fatal(err)
}
if updateAck.Accepted || updateAck.ErrorCode != transport.CredentialErrorGenerationConflict || updateAck.CredentialGeneration != 1 {
t.Fatalf("conflict ack = %#v", updateAck)
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("rejected connection did not close")
}
if strings.Contains(updateAck.Error, canary) || strings.Contains(logs.String(), canary) {
t.Fatal("credential appeared in acknowledgement or logs")
}
if d.hub.Len() != 0 {
t.Fatalf("rejected consumer registered: %d", d.hub.Len())
}
}
func TestCrossPlatformCoverageDaemonRuntimeCredentialInvalidFilterRejectedBeforeUpdate(t *testing.T) {
broker := runtimecred.New(runtimecred.Config{})
d := runtimeCredentialDaemon(broker, nil)
client, w, r, done := runRuntimeCredentialConnection(t, d)
defer client.Close()
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
CredentialMode: transport.CredentialModeRuntimeToken,
Filter: "[",
}); err != nil {
t.Fatal(err)
}
var helloAck transport.HelloAck
if err := r.ReadJSON(&helloAck); err != nil {
t.Fatal(err)
}
if err := w.WriteJSON(transport.CredentialUpdate{
Type: transport.FrameTypeCredentialUpdate,
ExpectedGeneration: helloAck.CredentialGeneration,
Token: "filter-canary-secret",
}); err != nil {
t.Fatal(err)
}
var updateAck transport.CredentialUpdateAck
if err := r.ReadJSON(&updateAck); err != nil {
t.Fatal(err)
}
if updateAck.Accepted || updateAck.ErrorCode != transport.CredentialErrorRegistration {
t.Fatalf("invalid filter ack = %#v", updateAck)
}
if broker.Generation() != 0 || d.hub.Len() != 0 {
t.Fatalf("invalid filter mutated state: generation=%d consumers=%d", broker.Generation(), d.hub.Len())
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("invalid filter connection did not close")
}
}
func TestCrossPlatformCoverageRuntimeCredentialShutdownReason(t *testing.T) {
if got := sourceShutdownReason(runtimecred.ErrRuntimeTokenRejected); got != transport.ByeReasonRuntimeTokenRejected {
t.Fatalf("runtime source shutdown reason = %q", got)
}
if got := sourceShutdownReason(errors.New("local source failed")); got != "shutdown" {
t.Fatalf("local source shutdown reason = %q", got)
}
if got := normalizedShutdownReason(transport.ByeReasonRuntimeTokenRejected); got != transport.ByeReasonRuntimeTokenRejected {
t.Fatalf("normalized runtime shutdown reason = %q", got)
}
for _, reasons := range [][]string{nil, {"peer-controlled"}} {
if got := normalizedShutdownReason(reasons...); got != "shutdown" {
t.Fatalf("normalized untrusted shutdown reason = %q", got)
}
}
}
func TestCrossPlatformCoverageDaemonRuntimeCredentialSourceRejectionBroadcastsTypedBye(t *testing.T) {
skipOnWindows(t, "uses Unix socket dial")
workDir := shortTempDir(t)
sockPath := filepath.Join(workDir, "bus.sock")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
broker := runtimecred.New(runtimecred.Config{RequireSeed: true, RequireActivation: true})
runDone := make(chan error, 1)
go func() {
runDone <- Run(ctx, Config{
WorkDir: workDir,
IPCEndpoint: sockPath,
ClientID: "runtime-client",
Edition: "open",
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: "0123456789abcdef",
SourceID: "open",
Source: &runtimeCredentialRejectSource{broker: broker},
CredentialBroker: broker,
})
}()
waitForFile(t, sockPath, 2*time.Second)
conn, err := transport.Dial(sockPath)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
r, w := transport.NewReader(conn), transport.NewWriter(conn)
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
CredentialMode: transport.CredentialModeRuntimeToken,
}); err != nil {
t.Fatal(err)
}
var helloAck transport.HelloAck
if err := r.ReadJSON(&helloAck); err != nil {
t.Fatal(err)
}
if err := w.WriteJSON(transport.CredentialUpdate{
Type: transport.FrameTypeCredentialUpdate,
ExpectedGeneration: helloAck.CredentialGeneration,
Token: "runtime-rejected-canary",
}); err != nil {
t.Fatal(err)
}
var updateAck transport.CredentialUpdateAck
if err := r.ReadJSON(&updateAck); err != nil || !updateAck.Accepted {
t.Fatalf("credential update ack = %#v, %v", updateAck, err)
}
var bye transport.Bye
if err := r.ReadJSON(&bye); err != nil {
t.Fatalf("read typed shutdown: %v", err)
}
if bye.Type != transport.FrameTypeBye || bye.Reason != transport.ByeReasonRuntimeTokenRejected {
t.Fatalf("shutdown frame = %#v", bye)
}
_ = conn.Close()
select {
case err := <-runDone:
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
t.Fatalf("Run() error = %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("runtime credential bus did not stop")
}
}
func hasTransportCapability(capabilities []string, want string) bool {
for _, capability := range capabilities {
if capability == want {
return true
}
}
return false
}
@@ -0,0 +1,101 @@
package consume
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
func eventCoreCredentialReader(t *testing.T, frame any) *transport.Reader {
t.Helper()
var buffer bytes.Buffer
if err := transport.NewWriter(&buffer).WriteJSON(frame); err != nil {
t.Fatal(err)
}
return transport.NewReader(&buffer)
}
func TestCrossPlatformCoverageEventCoreRuntimeNegotiationEdges(t *testing.T) {
capable := transport.HelloAck{Capabilities: []string{transport.CapabilityRuntimeTokenV1}}
if err := negotiateRuntimeToken(
transport.NewWriter(io.Discard),
transport.NewReader(strings.NewReader("")),
transport.HelloAck{TerminalReason: transport.ByeReasonRuntimeTokenRejected},
"token",
); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
t.Fatalf("terminal hello error = %v", err)
}
wantWriteErr := errors.New("write failed")
if err := negotiateRuntimeToken(
transport.NewWriter(errorWriter{err: wantWriteErr}),
transport.NewReader(strings.NewReader("")),
capable,
"token",
); !errors.Is(err, wantWriteErr) {
t.Fatalf("credential write error = %v", err)
}
if err := negotiateRuntimeToken(
transport.NewWriter(io.Discard),
transport.NewReader(strings.NewReader("")),
capable,
"token",
); !errors.Is(err, io.EOF) {
t.Fatalf("credential ack read error = %v", err)
}
if err := negotiateRuntimeToken(
transport.NewWriter(io.Discard),
eventCoreCredentialReader(t, transport.Heartbeat{Type: transport.FrameTypeHeartbeat}),
capable,
"token",
); err == nil || !strings.Contains(err.Error(), "unexpected runtime credential response") {
t.Fatalf("unexpected credential frame error = %v", err)
}
if err := negotiateRuntimeToken(
transport.NewWriter(io.Discard),
eventCoreCredentialReader(t, transport.CredentialUpdateAck{
Type: transport.FrameTypeCredentialUpdateAck,
Accepted: false,
ErrorCode: transport.CredentialErrorRuntimeRejected,
}),
capable,
"token",
); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
t.Fatalf("runtime rejected ack error = %v", err)
}
for _, code := range []string{
transport.CredentialErrorGenerationConflict,
transport.CredentialErrorInvalid,
transport.CredentialErrorRegistration,
transport.CredentialErrorRuntimeRejected,
transport.CredentialErrorInternal,
} {
if got := safeCredentialErrorCode(code); got != code {
t.Fatalf("safeCredentialErrorCode(%q) = %q", code, got)
}
}
if got := safeCredentialErrorCode("peer-controlled"); got != transport.CredentialErrorInternal {
t.Fatalf("unknown credential error code = %q", got)
}
}
func TestCrossPlatformCoverageEventCoreRunManyHandshakeFailure(t *testing.T) {
bus := newManyFakeBus(901, nil)
installManyDiscover(t, bus)
cfg := manyTestConfig(io.Discard, io.Discard)
cfg.RuntimeToken = "runtime-token"
err := RunMany(context.Background(), cfg, manyTestSpecs())
if !errors.Is(err, ErrRuntimeTokenUnsupported) || !strings.Contains(err.Error(), "runtime credential handshake") {
t.Fatalf("RunMany handshake error = %v", err)
}
}
+103
View File
@@ -21,9 +21,11 @@ import (
"io"
"net"
"os"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
@@ -45,6 +47,11 @@ type Config struct {
// personal_stream.
SpawnExtraArgs []string
// RuntimeToken is a host-supplied credential handed to a compatible bus
// only after capability negotiation over owner-only local IPC. It is never
// included in dry-run output, child argv, environment, or persisted state.
RuntimeToken string `json:"-" yaml:"-"`
// EventTypes / Filter / Compact are forwarded to the bus via Hello
// for server-side pushdown filtering.
EventTypes []string
@@ -132,6 +139,36 @@ type Config struct {
var discoverBus = busctl.Discover
var (
ErrRuntimeTokenUnsupported = errors.New("consume: event bus does not support secure runtime-token handoff")
ErrRuntimeTokenUpdate = errors.New("consume: event bus rejected runtime-token update")
)
// RuntimeTokenUnsupportedError is returned before the token is sent when the
// connected bus lacks the runtime_token_v1 capability.
type RuntimeTokenUnsupportedError struct {
BusPID int
}
func (e *RuntimeTokenUnsupportedError) Error() string {
return fmt.Sprintf("consume: running event bus (pid %d) does not support secure runtime-token handoff; let existing consumers exit, inspect with `dws event status --as user --format json`, preview cleanup with `dws event stop --as user --all --dry-run`, then confirm with `dws event stop --as user --all --yes` and retry", e.BusPID)
}
func (e *RuntimeTokenUnsupportedError) Unwrap() error { return ErrRuntimeTokenUnsupported }
// RuntimeTokenUpdateError reports a rejected credential CAS without carrying
// either the credential or peer-provided free-form error text.
type RuntimeTokenUpdateError struct {
Code string
Generation uint64
}
func (e *RuntimeTokenUpdateError) Error() string {
return fmt.Sprintf("consume: event bus rejected runtime-token update (code=%s, generation=%d)", e.Code, e.Generation)
}
func (e *RuntimeTokenUpdateError) Unwrap() error { return ErrRuntimeTokenUpdate }
// Run dials the bus (forking one if necessary), sends Hello, and writes
// each received Event frame as one NDJSON line to stdout. Blocks until
// ctx is cancelled, MaxEvents is reached, the bus sends Bye, or the
@@ -144,6 +181,7 @@ func Run(ctx context.Context, cfg Config) error {
if cfg.WorkDir == "" || cfg.IPCEndpoint == "" || cfg.ClientID == "" {
return errors.New("consume: WorkDir, IPCEndpoint, and ClientID are required")
}
cfg.RuntimeToken = strings.TrimSpace(cfg.RuntimeToken)
if cfg.Stdout == nil {
cfg.Stdout = os.Stdout
}
@@ -225,6 +263,9 @@ func Run(ctx context.Context, cfg Config) error {
SubscribeID: cfg.SubscribeID,
Compact: cfg.Compact,
}
if cfg.RuntimeToken != "" {
hello.CredentialMode = transport.CredentialModeRuntimeToken
}
if err := w.WriteJSON(hello); err != nil {
return fmt.Errorf("consume: write hello: %w", err)
}
@@ -236,6 +277,9 @@ func Run(ctx context.Context, cfg Config) error {
if ack.Type != transport.FrameTypeHelloAck {
return fmt.Errorf("consume: unexpected first frame type %q", ack.Type)
}
if err := negotiateRuntimeToken(w, r, ack, cfg.RuntimeToken); err != nil {
return err
}
if !cfg.Quiet {
// Contract: a fixed ready line on stderr BEFORE any stdout event.
// Parents block on stderr until this appears, then read stdout.
@@ -331,6 +375,9 @@ func Run(ctx context.Context, cfg Config) error {
case transport.FrameTypeBye:
var bye transport.Bye
_ = json.Unmarshal(raw, &bye)
if bye.Reason == transport.ByeReasonRuntimeTokenRejected {
return fmt.Errorf("consume: %w", runtimecred.ErrRuntimeTokenRejected)
}
if !cfg.Quiet {
fmt.Fprintf(cfg.Stderr, "[event] bus closing: %s\n", bye.Reason)
}
@@ -350,6 +397,62 @@ func Run(ctx context.Context, cfg Config) error {
}
}
func negotiateRuntimeToken(w *transport.Writer, r *transport.Reader, ack transport.HelloAck, token string) error {
if token == "" {
return nil
}
if ack.TerminalReason == transport.ByeReasonRuntimeTokenRejected {
return fmt.Errorf("consume: %w", runtimecred.ErrRuntimeTokenRejected)
}
if !hasCapability(ack.Capabilities, transport.CapabilityRuntimeTokenV1) {
return &RuntimeTokenUnsupportedError{BusPID: ack.BusPID}
}
if err := w.WriteJSON(transport.CredentialUpdate{
Type: transport.FrameTypeCredentialUpdate,
ExpectedGeneration: ack.CredentialGeneration,
Token: token,
}); err != nil {
return fmt.Errorf("consume: write runtime credential update: %w", err)
}
var updateAck transport.CredentialUpdateAck
if err := r.ReadJSON(&updateAck); err != nil {
return fmt.Errorf("consume: read runtime credential update ack: %w", err)
}
if updateAck.Type != transport.FrameTypeCredentialUpdateAck {
return errors.New("consume: unexpected runtime credential response frame")
}
if !updateAck.Accepted {
code := safeCredentialErrorCode(updateAck.ErrorCode)
if code == transport.CredentialErrorRuntimeRejected {
return fmt.Errorf("consume: %w", runtimecred.ErrRuntimeTokenRejected)
}
return &RuntimeTokenUpdateError{Code: code, Generation: updateAck.CredentialGeneration}
}
return nil
}
func safeCredentialErrorCode(code string) string {
switch code {
case transport.CredentialErrorGenerationConflict,
transport.CredentialErrorInvalid,
transport.CredentialErrorRegistration,
transport.CredentialErrorRuntimeRejected,
transport.CredentialErrorInternal:
return code
default:
return transport.CredentialErrorInternal
}
}
func hasCapability(capabilities []string, want string) bool {
for _, capability := range capabilities {
if capability == want {
return true
}
}
return false
}
// closeOnContext spawns a goroutine that closes conn when ctx is done.
// This unblocks any pending Read on conn so the main loop can return.
func closeOnContext(ctx context.Context, conn net.Conn) {
+18 -2
View File
@@ -16,6 +16,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
@@ -53,6 +54,7 @@ func RunMany(ctx context.Context, cfg Config, specs []ConsumerSpec) error {
if cfg.WorkDir == "" || cfg.IPCEndpoint == "" || cfg.ClientID == "" {
return errors.New("consume: WorkDir, IPCEndpoint, and ClientID are required")
}
cfg.RuntimeToken = strings.TrimSpace(cfg.RuntimeToken)
if len(specs) < 2 {
return errors.New("consume: RunMany requires at least two consumers")
}
@@ -127,14 +129,18 @@ func RunMany(ctx context.Context, cfg Config, specs []ConsumerSpec) error {
}
sessions = append(sessions, session)
closeOnContext(ctx, session.conn)
if err := session.w.WriteJSON(transport.Hello{
hello := transport.Hello{
Type: transport.FrameTypeHello,
ConsumerPID: os.Getpid(),
EventTypes: spec.EventTypes,
Filter: spec.Filter,
SubscribeID: spec.SubscribeID,
Compact: cfg.Compact,
}); err != nil {
}
if cfg.RuntimeToken != "" {
hello.CredentialMode = transport.CredentialModeRuntimeToken
}
if err := session.w.WriteJSON(hello); err != nil {
return fmt.Errorf("consume: write hello for %s: %w", spec.EventKey, err)
}
if err := session.r.ReadJSON(&session.ack); err != nil {
@@ -143,9 +149,16 @@ func RunMany(ctx context.Context, cfg Config, specs []ConsumerSpec) error {
if session.ack.Type != transport.FrameTypeHelloAck {
return fmt.Errorf("consume: unexpected first frame type %q for %s", session.ack.Type, spec.EventKey)
}
// Verify that every connection reached the same bus before handing a
// runtime credential to it. Discovery is expected to converge on one
// daemon, but a stale endpoint/race must not propagate the host token to
// an unrelated process merely so we can report the PID mismatch later.
if len(sessions) > 1 && session.ack.BusPID != sessions[0].ack.BusPID {
return fmt.Errorf("consume: consumers connected to different bus processes (%d and %d)", sessions[0].ack.BusPID, session.ack.BusPID)
}
if err := negotiateRuntimeToken(session.w, session.r, session.ack, cfg.RuntimeToken); err != nil {
return fmt.Errorf("consume: runtime credential handshake for %s: %w", spec.EventKey, err)
}
}
if !cfg.Quiet {
@@ -225,6 +238,9 @@ func RunMany(ctx context.Context, cfg Config, specs []ConsumerSpec) error {
case transport.FrameTypeBye:
var bye transport.Bye
_ = json.Unmarshal(frame.raw, &bye)
if bye.Reason == transport.ByeReasonRuntimeTokenRejected {
return fmt.Errorf("consume: %w", runtimecred.ErrRuntimeTokenRejected)
}
if bye.Reason == transport.ByeReasonSubscriptionStopped {
delete(active, frame.index)
_ = sessions[frame.index].conn.Close()
+117 -12
View File
@@ -16,6 +16,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
@@ -37,23 +38,25 @@ func (b *synchronizedBuffer) String() string {
}
type manyFakeBus struct {
client net.Conn
server net.Conn
hello chan transport.Hello
acked chan struct{}
ackGate <-chan struct{}
ack transport.HelloAck
send chan any
client net.Conn
server net.Conn
hello chan transport.Hello
credentialUpdate chan transport.CredentialUpdate
acked chan struct{}
ackGate <-chan struct{}
ack transport.HelloAck
send chan any
}
func newManyFakeBus(busPID int, ackGate <-chan struct{}) *manyFakeBus {
client, server := net.Pipe()
f := &manyFakeBus{
client: client,
server: server,
hello: make(chan transport.Hello, 1),
acked: make(chan struct{}),
ackGate: ackGate,
client: client,
server: server,
hello: make(chan transport.Hello, 1),
credentialUpdate: make(chan transport.CredentialUpdate, 1),
acked: make(chan struct{}),
ackGate: ackGate,
ack: transport.HelloAck{
Type: transport.FrameTypeHelloAck,
BusPID: busPID,
@@ -85,6 +88,23 @@ func (f *manyFakeBus) serve() {
if err := w.WriteJSON(f.ack); err != nil {
return
}
if hello.CredentialMode == transport.CredentialModeRuntimeToken {
if !hasCapability(f.ack.Capabilities, transport.CapabilityRuntimeTokenV1) {
return
}
var update transport.CredentialUpdate
if err := r.ReadJSON(&update); err != nil {
return
}
f.credentialUpdate <- update
if err := w.WriteJSON(transport.CredentialUpdateAck{
Type: transport.FrameTypeCredentialUpdateAck,
Accepted: true,
CredentialGeneration: f.ack.CredentialGeneration + 1,
}); err != nil {
return
}
}
close(f.acked)
go func() {
for {
@@ -221,6 +241,91 @@ func TestRunManyWaitsForAllConsumersAndStopsOneAtATime(t *testing.T) {
}
}
func TestCrossPlatformCoverageRunManyRuntimeTokenUsesEachConnectionGeneration(t *testing.T) {
const canary = "many-runtime-canary"
busA := newManyFakeBus(111, nil)
busB := newManyFakeBus(111, nil)
busA.ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
busB.ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
busA.ack.CredentialGeneration = 2
busB.ack.CredentialGeneration = 8
installManyDiscover(t, busA, busB)
cfg := manyTestConfig(io.Discard, io.Discard)
cfg.RuntimeToken = canary
done := make(chan error, 1)
go func() { done <- RunMany(context.Background(), cfg, manyTestSpecs()) }()
updateA := <-busA.credentialUpdate
updateB := <-busB.credentialUpdate
if updateA.Token != canary || updateA.ExpectedGeneration != 2 {
t.Fatal("first connection used the wrong credential or generation")
}
if updateB.Token != canary || updateB.ExpectedGeneration != 8 {
t.Fatal("second connection used the wrong credential or generation")
}
busA.send <- transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"}
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatal("RunMany did not stop")
}
}
func TestCrossPlatformCoverageRunManyRuntimeTokenRejectsDifferentBusBeforeSecondCredential(t *testing.T) {
const canary = "many-mismatched-bus-canary"
busA := newManyFakeBus(111, nil)
busB := newManyFakeBus(222, nil)
busA.ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
busB.ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
installManyDiscover(t, busA, busB)
cfg := manyTestConfig(io.Discard, io.Discard)
cfg.RuntimeToken = canary
err := RunMany(context.Background(), cfg, manyTestSpecs())
if err == nil || !strings.Contains(err.Error(), "different bus processes") {
t.Fatalf("RunMany() error = %v", err)
}
if update := <-busA.credentialUpdate; update.Token != canary {
t.Fatal("first bus did not receive the negotiated credential")
}
select {
case update := <-busB.credentialUpdate:
t.Fatalf("mismatched second bus received credential: generation=%d", update.ExpectedGeneration)
default:
}
}
func TestCrossPlatformCoverageRunManyRuntimeTokenRejectedByeReturnsTypedError(t *testing.T) {
busA := newManyFakeBus(333, nil)
busB := newManyFakeBus(333, nil)
busA.ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
busB.ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
installManyDiscover(t, busA, busB)
cfg := manyTestConfig(io.Discard, io.Discard)
cfg.RuntimeToken = "many-runtime-rejected-canary"
done := make(chan error, 1)
go func() { done <- RunMany(context.Background(), cfg, manyTestSpecs()) }()
<-busA.credentialUpdate
<-busB.credentialUpdate
busA.send <- transport.Bye{
Type: transport.FrameTypeBye,
Reason: transport.ByeReasonRuntimeTokenRejected,
}
select {
case err := <-done:
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
t.Fatalf("RunMany() error = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("RunMany did not return runtime credential rejection")
}
}
func TestRunManyMaxEventsIsSharedAcrossConsumers(t *testing.T) {
busA := newManyFakeBus(202, nil)
busB := newManyFakeBus(202, nil)
@@ -0,0 +1,242 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package consume
import (
"bytes"
"context"
"errors"
"io"
"net"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
func installRuntimeCredentialDiscover(t *testing.T, serve func(net.Conn)) {
t.Helper()
oldDiscover := discoverBus
done := make(chan struct{})
discoverBus = func(busctl.DiscoverConfig) (net.Conn, error) {
client, server := net.Pipe()
go func() {
defer close(done)
defer server.Close()
serve(server)
}()
return client, nil
}
t.Cleanup(func() {
discoverBus = oldDiscover
select {
case <-done:
case <-time.After(time.Second):
t.Error("fake runtime credential bus did not stop")
}
})
}
func TestCrossPlatformCoverageRunRuntimeTokenNegotiatesBeforeReady(t *testing.T) {
const canary = "consume-runtime-canary"
helloSeen := make(chan transport.Hello, 1)
updateSeen := make(chan transport.CredentialUpdate, 1)
installRuntimeCredentialDiscover(t, func(conn net.Conn) {
r, w := transport.NewReader(conn), transport.NewWriter(conn)
var hello transport.Hello
if err := r.ReadJSON(&hello); err != nil {
return
}
helloSeen <- hello
_ = w.WriteJSON(transport.HelloAck{
Type: transport.FrameTypeHelloAck,
BusPID: 71,
Capabilities: []string{transport.CapabilityRuntimeTokenV1},
CredentialGeneration: 3,
})
var update transport.CredentialUpdate
if err := r.ReadJSON(&update); err != nil {
return
}
updateSeen <- update
_ = w.WriteJSON(transport.CredentialUpdateAck{
Type: transport.FrameTypeCredentialUpdateAck,
Accepted: true,
CredentialGeneration: 4,
})
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "done"})
})
var stderr bytes.Buffer
cfg := validRunConfig()
cfg.RuntimeToken = " " + canary + " "
cfg.Stderr = &stderr
if err := Run(context.Background(), cfg); err != nil {
t.Fatal(err)
}
hello := <-helloSeen
if hello.CredentialMode != transport.CredentialModeRuntimeToken {
t.Fatalf("credential mode = %q", hello.CredentialMode)
}
update := <-updateSeen
if update.ExpectedGeneration != 3 || update.Token != canary {
t.Fatal("credential update used the wrong token or generation")
}
if !strings.Contains(stderr.String(), "[event] ready bus_pid=71") {
t.Fatalf("ready marker missing: %s", stderr.String())
}
}
func TestCrossPlatformCoverageRunRuntimeTokenMissingCapabilityFailsBeforeSendingSecretOrReady(t *testing.T) {
const canary = "unsupported-canary-secret"
peerBytes := make(chan string, 1)
installRuntimeCredentialDiscover(t, func(conn net.Conn) {
r, w := transport.NewReader(conn), transport.NewWriter(conn)
var hello transport.Hello
if err := r.ReadJSON(&hello); err != nil {
return
}
_ = w.WriteJSON(transport.HelloAck{Type: transport.FrameTypeHelloAck, BusPID: 72})
raw, _ := io.ReadAll(conn)
peerBytes <- string(raw)
})
var stderr bytes.Buffer
cfg := validRunConfig()
cfg.RuntimeToken = canary
cfg.Stderr = &stderr
err := Run(context.Background(), cfg)
if !errors.Is(err, ErrRuntimeTokenUnsupported) {
t.Fatalf("Run error = %v", err)
}
if strings.Contains(err.Error(), canary) {
t.Fatal("unsupported-bus error contained runtime token")
}
for _, recoveryStep := range []string{
"dws event status --as user --format json",
"dws event stop --as user --all --dry-run",
"dws event stop --as user --all --yes",
} {
if !strings.Contains(err.Error(), recoveryStep) {
t.Fatalf("unsupported-bus error missing recovery step %q: %v", recoveryStep, err)
}
}
if strings.Contains(stderr.String(), "[event] ready") {
t.Fatal("ready marker was written before capability negotiation succeeded")
}
if got := <-peerBytes; strings.Contains(got, canary) || got != "" {
t.Fatal("client sent data after unsupported capability acknowledgement")
}
}
func TestCrossPlatformCoverageRunRuntimeTokenRejectedDoesNotSurfacePeerText(t *testing.T) {
const canary = "rejected-canary-secret"
installRuntimeCredentialDiscover(t, func(conn net.Conn) {
r, w := transport.NewReader(conn), transport.NewWriter(conn)
var hello transport.Hello
if err := r.ReadJSON(&hello); err != nil {
return
}
_ = w.WriteJSON(transport.HelloAck{
Type: transport.FrameTypeHelloAck,
Capabilities: []string{transport.CapabilityRuntimeTokenV1},
})
var update transport.CredentialUpdate
if err := r.ReadJSON(&update); err != nil {
return
}
_ = w.WriteJSON(transport.CredentialUpdateAck{
Type: transport.FrameTypeCredentialUpdateAck,
Accepted: false,
ErrorCode: "malicious-code-" + update.Token,
Error: "malicious echo " + update.Token,
})
})
cfg := validRunConfig()
cfg.RuntimeToken = canary
err := Run(context.Background(), cfg)
if !errors.Is(err, ErrRuntimeTokenUpdate) {
t.Fatalf("Run error = %v", err)
}
if strings.Contains(err.Error(), canary) {
t.Fatal("credential update error contained peer-provided token text")
}
}
func TestCrossPlatformCoverageRuntimeTokenNeverAppearsInDryRun(t *testing.T) {
const canary = "dry-run-canary-secret"
var output bytes.Buffer
cfg := validRunConfig()
cfg.RuntimeToken = canary
PrintDryRun(&output, cfg)
if strings.Contains(output.String(), canary) || strings.Contains(output.String(), "RuntimeToken") {
t.Fatal("dry-run output contained runtime-token data")
}
}
func TestCrossPlatformCoverageRunWhitespaceRuntimeTokenUsesLegacyHandshake(t *testing.T) {
helloSeen := make(chan transport.Hello, 1)
installRuntimeCredentialDiscover(t, func(conn net.Conn) {
r, w := transport.NewReader(conn), transport.NewWriter(conn)
var hello transport.Hello
if err := r.ReadJSON(&hello); err != nil {
return
}
helloSeen <- hello
_ = w.WriteJSON(transport.HelloAck{Type: transport.FrameTypeHelloAck})
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "done"})
})
cfg := validRunConfig()
cfg.RuntimeToken = " "
if err := Run(context.Background(), cfg); err != nil {
t.Fatal(err)
}
if hello := <-helloSeen; hello.CredentialMode != "" {
t.Fatalf("whitespace token enabled mode %q", hello.CredentialMode)
}
}
func TestCrossPlatformCoverageRunRuntimeTokenRejectedByeReturnsTypedError(t *testing.T) {
installRuntimeCredentialDiscover(t, func(conn net.Conn) {
r, w := transport.NewReader(conn), transport.NewWriter(conn)
var hello transport.Hello
if err := r.ReadJSON(&hello); err != nil {
return
}
_ = w.WriteJSON(transport.HelloAck{
Type: transport.FrameTypeHelloAck,
BusPID: 73,
Capabilities: []string{transport.CapabilityRuntimeTokenV1},
})
var update transport.CredentialUpdate
if err := r.ReadJSON(&update); err != nil {
return
}
_ = w.WriteJSON(transport.CredentialUpdateAck{
Type: transport.FrameTypeCredentialUpdateAck,
Accepted: true,
CredentialGeneration: 1,
})
_ = w.WriteJSON(transport.Bye{
Type: transport.FrameTypeBye,
Reason: transport.ByeReasonRuntimeTokenRejected,
})
})
var stderr bytes.Buffer
cfg := validRunConfig()
cfg.RuntimeToken = "runtime-rejected-canary"
cfg.Stderr = &stderr
err := Run(context.Background(), cfg)
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
t.Fatalf("Run() error = %v", err)
}
if strings.Contains(stderr.String(), "reason: bus_shutdown") {
t.Fatalf("runtime rejection was reported as a successful exit: %s", stderr.String())
}
}
+3 -2
View File
@@ -113,8 +113,9 @@ func IsValidationError(err error) bool {
// human-readable block. Called by Run when cfg.DryRun is true. Format
// avoids JSON so users can `dws event consume --dry-run | head` cleanly.
//
// Secret-bearing fields are never present in Config (credentials never
// reach this layer), so no redaction is required here.
// RuntimeToken is the only secret-bearing Config field and is deliberately
// not read or rendered here. Keep this function allowlist-based: never switch
// it to generic struct serialization.
func PrintDryRun(w io.Writer, cfg Config) {
if w == nil {
return
+300
View File
@@ -0,0 +1,300 @@
// 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 runtimecred provides an in-memory credential broker for event bus
// processes. Runtime credentials are never persisted by this package.
package runtimecred
import (
"context"
"errors"
"fmt"
"strings"
"sync"
)
// DefaultMaxTokenBytes bounds credentials accepted over the local IPC
// channel. Access tokens are normally only a few KiB; the larger limit leaves
// ample room for future token formats while avoiding accidental large secret
// allocations.
const DefaultMaxTokenBytes = 64 << 10
var (
ErrEmptyToken = errors.New("runtime credential: token is empty")
ErrTokenTooLarge = errors.New("runtime credential: token exceeds size limit")
ErrCredentialUnavailable = errors.New("runtime credential: no credential resolver is available")
ErrLocalRefreshUnavailable = errors.New("runtime credential: no local refresh callback is available")
ErrRuntimeTokenRejected = &RuntimeTokenRejectedError{}
)
// RuntimeTokenRejectedError means the currently installed runtime token was
// rejected and no newer runtime token is available. It deliberately carries
// no token or server response data so it is safe to surface to users and logs.
type RuntimeTokenRejectedError struct{}
func (*RuntimeTokenRejectedError) Error() string {
return "event runtime token was rejected; retry with a fresh host credential"
}
func (*RuntimeTokenRejectedError) Is(target error) bool {
_, ok := target.(*RuntimeTokenRejectedError)
return ok
}
// GenerationConflictError reports a failed compare-and-swap update.
type GenerationConflictError struct {
Expected uint64
Actual uint64
}
func (e *GenerationConflictError) Error() string {
return fmt.Sprintf("runtime credential: generation conflict (expected %d, actual %d)", e.Expected, e.Actual)
}
// ResolveFunc resolves the existing local OAuth credential when no runtime
// credential has been installed.
type ResolveFunc func(context.Context) (string, error)
// RefreshFunc refreshes a rejected local OAuth credential. It is never called
// after a runtime credential has been installed.
type RefreshFunc func(context.Context, string) (string, error)
type Config struct {
LocalResolve ResolveFunc
LocalRefresh RefreshFunc
RequireSeed bool
// RequireActivation keeps the first installed runtime credential pending
// until Activate is called. Detached buses use it to register the consumer
// before ticket acquisition can emit or fail.
RequireActivation bool
MaxTokenBytes int
}
// Broker holds at most one runtime credential. All state, including the
// credential generation, is process-local and concurrency-safe.
type Broker struct {
localResolve ResolveFunc
localRefresh RefreshFunc
requireSeed bool
requireActivation bool
maxBytes int
mu sync.Mutex
token string
generation uint64
active bool
changed chan struct{}
}
func New(cfg Config) *Broker {
maxBytes := cfg.MaxTokenBytes
if maxBytes <= 0 {
maxBytes = DefaultMaxTokenBytes
}
return &Broker{
localResolve: cfg.LocalResolve,
localRefresh: cfg.LocalRefresh,
requireSeed: cfg.RequireSeed,
requireActivation: cfg.RequireActivation,
maxBytes: maxBytes,
active: !cfg.RequireActivation,
changed: make(chan struct{}),
}
}
// Generation returns the current runtime credential generation. Generation 0
// means that no runtime credential has been installed yet.
func (b *Broker) Generation() uint64 {
if b == nil {
return 0
}
b.mu.Lock()
defer b.mu.Unlock()
return b.generation
}
// Update atomically installs token when expectedGeneration matches the
// current generation. Reinstalling the same token is idempotent, including
// when another concurrent writer already installed it.
func (b *Broker) Update(expectedGeneration uint64, token string) (uint64, error) {
if b == nil {
return 0, ErrCredentialUnavailable
}
normalized, err := b.validate(token)
if err != nil {
return b.Generation(), err
}
b.mu.Lock()
defer b.mu.Unlock()
if b.token == normalized {
return b.generation, nil
}
if expectedGeneration != b.generation {
return b.generation, &GenerationConflictError{Expected: expectedGeneration, Actual: b.generation}
}
b.token = normalized
b.generation++
if b.active {
b.signalChangedLocked()
}
return b.generation, nil
}
// Activate publishes a pending first runtime credential after the bus has
// registered the initiating consumer. It is an idempotent generation-checked
// no-op for brokers that do not require activation.
func (b *Broker) Activate(expectedGeneration uint64) (uint64, error) {
if b == nil {
return 0, ErrCredentialUnavailable
}
b.mu.Lock()
defer b.mu.Unlock()
if expectedGeneration != b.generation {
return b.generation, &GenerationConflictError{Expected: expectedGeneration, Actual: b.generation}
}
if b.token == "" {
return b.generation, ErrCredentialUnavailable
}
if b.active {
return b.generation, nil
}
b.active = true
b.signalChangedLocked()
return b.generation, nil
}
// Resolve returns the runtime credential when installed. In RequireSeed mode
// it waits until Update installs one; otherwise it preserves the existing
// local resolver behavior until a runtime credential arrives.
func (b *Broker) Resolve(ctx context.Context) (string, error) {
if b == nil {
return "", ErrCredentialUnavailable
}
for {
token, wait, requireSeed, active := b.snapshot()
if token != "" && active {
return token, nil
}
if requireSeed || (token != "" && !active) {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-wait:
continue
}
}
if b.localResolve == nil {
return "", ErrCredentialUnavailable
}
local, err := b.localResolve(ctx)
if err != nil {
return "", err
}
if runtime, _, _, runtimeActive := b.snapshot(); runtime != "" && runtimeActive {
return runtime, nil
}
return validateResolved(local)
}
}
// RefreshRejected returns a newer runtime token if one was installed after
// rejectedToken was used. If the installed runtime token itself was rejected,
// it returns RuntimeTokenRejectedError and never invokes local OAuth refresh.
func (b *Broker) RefreshRejected(ctx context.Context, rejectedToken string) (string, error) {
if b == nil {
return "", ErrCredentialUnavailable
}
for {
token, wait, requireSeed, active := b.snapshot()
if token != "" && active {
if token != rejectedToken {
return token, nil
}
return "", &RuntimeTokenRejectedError{}
}
if requireSeed || (token != "" && !active) {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-wait:
continue
}
}
if b.localRefresh == nil {
return "", ErrLocalRefreshUnavailable
}
refreshed, err := b.localRefresh(ctx, rejectedToken)
if err != nil {
return "", err
}
if runtime, _, _, runtimeActive := b.snapshot(); runtime != "" && runtimeActive {
if runtime != rejectedToken {
return runtime, nil
}
return "", &RuntimeTokenRejectedError{}
}
return validateResolved(refreshed)
}
}
// ClassifyRejectedAfterRetry is called when a token returned by
// RefreshRejected was itself rejected. When a still newer runtime credential
// is already installed, superseded is true so the source may reconnect and
// resolve that generation without a second in-attempt retry. When the rejected
// token is still current, the fixed typed rejection is returned. With no
// runtime credential installed it preserves local OAuth behavior by returning
// (false, nil).
func (b *Broker) ClassifyRejectedAfterRetry(rejectedToken string) (superseded bool, err error) {
if b == nil {
return false, ErrCredentialUnavailable
}
token, _, _, active := b.snapshot()
if token == "" || !active {
return false, nil
}
if token != strings.TrimSpace(rejectedToken) {
return true, nil
}
return false, &RuntimeTokenRejectedError{}
}
func (b *Broker) snapshot() (string, <-chan struct{}, bool, bool) {
b.mu.Lock()
defer b.mu.Unlock()
return b.token, b.changed, b.requireSeed, b.active
}
func (b *Broker) signalChangedLocked() {
close(b.changed)
b.changed = make(chan struct{})
}
func (b *Broker) validate(token string) (string, error) {
normalized := strings.TrimSpace(token)
if normalized == "" {
return "", ErrEmptyToken
}
if len(normalized) > b.maxBytes {
return "", ErrTokenTooLarge
}
return normalized, nil
}
func validateResolved(token string) (string, error) {
token = strings.TrimSpace(token)
if token == "" {
return "", ErrCredentialUnavailable
}
return token, nil
}
+203
View File
@@ -0,0 +1,203 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package runtimecred
import (
"context"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestCrossPlatformCoverageBrokerLocalFallbackAndRuntimePrecedence(t *testing.T) {
var localCalls atomic.Int32
b := New(Config{LocalResolve: func(context.Context) (string, error) {
localCalls.Add(1)
return "local", nil
}})
if got, err := b.Resolve(context.Background()); err != nil || got != "local" {
t.Fatalf("local Resolve = %q, %v", got, err)
}
gen, err := b.Update(0, " runtime ")
if err != nil || gen != 1 {
t.Fatalf("Update = %d, %v", gen, err)
}
if got, err := b.Resolve(context.Background()); err != nil || got != "runtime" {
t.Fatalf("runtime Resolve = %q, %v", got, err)
}
if localCalls.Load() != 1 {
t.Fatalf("local resolver calls = %d", localCalls.Load())
}
}
func TestCrossPlatformCoverageBrokerRequireSeedWaitsAndCancels(t *testing.T) {
b := New(Config{RequireSeed: true})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { _, err := b.Resolve(ctx); done <- err }()
cancel()
if err := <-done; !errors.Is(err, context.Canceled) {
t.Fatalf("Resolve error = %v", err)
}
resolved := make(chan string, 1)
go func() {
token, _ := b.Resolve(context.Background())
resolved <- token
}()
select {
case <-resolved:
t.Fatal("Resolve returned before seed")
case <-time.After(20 * time.Millisecond):
}
if _, err := b.Update(0, "seed"); err != nil {
t.Fatal(err)
}
if got := <-resolved; got != "seed" {
t.Fatalf("Resolve = %q", got)
}
}
func TestCrossPlatformCoverageBrokerUpdateCASIdempotenceAndConcurrentConflict(t *testing.T) {
b := New(Config{})
if gen, err := b.Update(0, "same"); err != nil || gen != 1 {
t.Fatalf("first update = %d, %v", gen, err)
}
if gen, err := b.Update(0, "same"); err != nil || gen != 1 {
t.Fatalf("idempotent stale update = %d, %v", gen, err)
}
if _, err := b.Update(0, "different"); err == nil {
t.Fatal("expected generation conflict")
} else {
var conflict *GenerationConflictError
if !errors.As(err, &conflict) || conflict.Actual != 1 {
t.Fatalf("conflict = %#v, %v", conflict, err)
}
}
b = New(Config{})
start := make(chan struct{})
var successes atomic.Int32
var conflicts atomic.Int32
var wg sync.WaitGroup
for _, token := range []string{"a", "b"} {
wg.Add(1)
go func(token string) {
defer wg.Done()
<-start
_, err := b.Update(0, token)
if err == nil {
successes.Add(1)
return
}
var conflict *GenerationConflictError
if errors.As(err, &conflict) {
conflicts.Add(1)
}
}(token)
}
close(start)
wg.Wait()
if successes.Load() != 1 || conflicts.Load() != 1 {
t.Fatalf("successes=%d conflicts=%d", successes.Load(), conflicts.Load())
}
}
func TestCrossPlatformCoverageBrokerRefreshRejectedRuntimeNeverFallsBack(t *testing.T) {
var refreshCalls atomic.Int32
b := New(Config{LocalRefresh: func(context.Context, string) (string, error) {
refreshCalls.Add(1)
return "local-new", nil
}})
if _, err := b.Update(0, "runtime-a"); err != nil {
t.Fatal(err)
}
if _, err := b.RefreshRejected(context.Background(), "runtime-a"); !errors.Is(err, ErrRuntimeTokenRejected) {
t.Fatalf("same token refresh error = %v", err)
}
if refreshCalls.Load() != 0 {
t.Fatalf("local refresh called %d times", refreshCalls.Load())
}
if _, err := b.Update(1, "runtime-b"); err != nil {
t.Fatal(err)
}
if got, err := b.RefreshRejected(context.Background(), "runtime-a"); err != nil || got != "runtime-b" {
t.Fatalf("rotated refresh = %q, %v", got, err)
}
if superseded, err := b.ClassifyRejectedAfterRetry("runtime-b"); superseded || !errors.Is(err, ErrRuntimeTokenRejected) {
t.Fatalf("current retry rejection = superseded %v, error %v", superseded, err)
}
if _, err := b.Update(2, "runtime-c"); err != nil {
t.Fatal(err)
}
if superseded, err := b.ClassifyRejectedAfterRetry("runtime-b"); !superseded || err != nil {
t.Fatalf("newer generation classification = superseded %v, error %v", superseded, err)
}
localOnly := New(Config{})
if superseded, err := localOnly.ClassifyRejectedAfterRetry("local"); superseded || err != nil {
t.Fatalf("local-only classification = superseded %v, error %v", superseded, err)
}
}
func TestCrossPlatformCoverageBrokerDefersSeedUntilActivation(t *testing.T) {
b := New(Config{RequireSeed: true, RequireActivation: true})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
resolved := make(chan string, 1)
errs := make(chan error, 1)
go func() {
token, err := b.Resolve(ctx)
if err != nil {
errs <- err
return
}
resolved <- token
}()
generation, err := b.Update(0, "runtime-a")
if err != nil || generation != 1 {
t.Fatalf("Update() = generation %d, err %v", generation, err)
}
select {
case token := <-resolved:
t.Fatalf("Resolve() returned before activation: %q", token)
case err := <-errs:
t.Fatalf("Resolve() failed before activation: %v", err)
case <-time.After(20 * time.Millisecond):
}
if _, err := b.Activate(0); err == nil {
t.Fatal("Activate() with stale generation unexpectedly succeeded")
}
if activeGeneration, err := b.Activate(generation); err != nil || activeGeneration != generation {
t.Fatalf("Activate() = generation %d, err %v", activeGeneration, err)
}
select {
case token := <-resolved:
if token != "runtime-a" {
t.Fatalf("Resolve() token = %q", token)
}
case err := <-errs:
t.Fatalf("Resolve() failed after activation: %v", err)
case <-time.After(time.Second):
t.Fatal("Resolve() remained blocked after activation")
}
}
func TestCrossPlatformCoverageBrokerRejectsInvalidTokensWithoutEcho(t *testing.T) {
b := New(Config{MaxTokenBytes: 4})
for _, token := range []string{" ", "secret-token"} {
_, err := b.Update(0, token)
if err == nil {
t.Fatalf("Update(%q) succeeded", token)
}
if strings.Contains(err.Error(), token) {
t.Fatal("validation error contained rejected token")
}
}
}
@@ -0,0 +1,150 @@
package runtimecred
import (
"context"
"errors"
"testing"
"time"
)
func TestCrossPlatformCoverageEventCoreBrokerEdges(t *testing.T) {
rejected := &RuntimeTokenRejectedError{}
if rejected.Error() == "" || !errors.Is(rejected, ErrRuntimeTokenRejected) || rejected.Is(errors.New("other")) {
t.Fatal("runtime rejection error contract failed")
}
conflict := &GenerationConflictError{Expected: 2, Actual: 3}
if conflict.Error() == "" {
t.Fatal("generation conflict error is empty")
}
var nilBroker *Broker
if nilBroker.Generation() != 0 {
t.Fatal("nil broker generation is non-zero")
}
if _, err := nilBroker.Update(0, "token"); !errors.Is(err, ErrCredentialUnavailable) {
t.Fatalf("nil Update error = %v", err)
}
if _, err := nilBroker.Activate(0); !errors.Is(err, ErrCredentialUnavailable) {
t.Fatalf("nil Activate error = %v", err)
}
if _, err := nilBroker.Resolve(context.Background()); !errors.Is(err, ErrCredentialUnavailable) {
t.Fatalf("nil Resolve error = %v", err)
}
if _, err := nilBroker.RefreshRejected(context.Background(), "token"); !errors.Is(err, ErrCredentialUnavailable) {
t.Fatalf("nil RefreshRejected error = %v", err)
}
if superseded, err := nilBroker.ClassifyRejectedAfterRetry("token"); superseded || !errors.Is(err, ErrCredentialUnavailable) {
t.Fatalf("nil classification = %v, %v", superseded, err)
}
b := New(Config{})
if _, err := b.Activate(0); !errors.Is(err, ErrCredentialUnavailable) {
t.Fatalf("empty Activate error = %v", err)
}
if _, err := b.Update(0, "token"); err != nil {
t.Fatal(err)
}
if generation, err := b.Activate(1); err != nil || generation != 1 {
t.Fatalf("active Activate = %d, %v", generation, err)
}
if _, err := New(Config{}).Resolve(context.Background()); !errors.Is(err, ErrCredentialUnavailable) {
t.Fatalf("missing local resolver error = %v", err)
}
wantResolveErr := errors.New("resolve failed")
if _, err := New(Config{LocalResolve: func(context.Context) (string, error) {
return "", wantResolveErr
}}).Resolve(context.Background()); !errors.Is(err, wantResolveErr) {
t.Fatalf("local resolve error = %v", err)
}
if _, err := New(Config{LocalResolve: func(context.Context) (string, error) {
return " ", nil
}}).Resolve(context.Background()); !errors.Is(err, ErrCredentialUnavailable) {
t.Fatalf("empty resolved credential error = %v", err)
}
var racingResolve *Broker
racingResolve = New(Config{LocalResolve: func(context.Context) (string, error) {
_, err := racingResolve.Update(0, "runtime-wins")
return "local", err
}})
if token, err := racingResolve.Resolve(context.Background()); err != nil || token != "runtime-wins" {
t.Fatalf("runtime precedence after resolve = %q, %v", token, err)
}
seed := New(Config{RequireSeed: true})
canceled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := seed.Resolve(canceled); !errors.Is(err, context.Canceled) {
t.Fatalf("seed cancellation = %v", err)
}
if _, err := New(Config{}).RefreshRejected(context.Background(), "local"); !errors.Is(err, ErrLocalRefreshUnavailable) {
t.Fatalf("missing local refresh error = %v", err)
}
wantRefreshErr := errors.New("refresh failed")
if _, err := New(Config{LocalRefresh: func(context.Context, string) (string, error) {
return "", wantRefreshErr
}}).RefreshRejected(context.Background(), "local"); !errors.Is(err, wantRefreshErr) {
t.Fatalf("local refresh error = %v", err)
}
if _, err := New(Config{LocalRefresh: func(context.Context, string) (string, error) {
return " ", nil
}}).RefreshRejected(context.Background(), "local"); !errors.Is(err, ErrCredentialUnavailable) {
t.Fatalf("empty refreshed credential error = %v", err)
}
var racingRefresh *Broker
racingRefresh = New(Config{LocalRefresh: func(context.Context, string) (string, error) {
_, err := racingRefresh.Update(0, "runtime-new")
return "local-new", err
}})
if token, err := racingRefresh.RefreshRejected(context.Background(), "runtime-old"); err != nil || token != "runtime-new" {
t.Fatalf("runtime precedence after refresh = %q, %v", token, err)
}
var sameRefresh *Broker
sameRefresh = New(Config{LocalRefresh: func(context.Context, string) (string, error) {
_, err := sameRefresh.Update(0, "same")
return "local-new", err
}})
if _, err := sameRefresh.RefreshRejected(context.Background(), "same"); !errors.Is(err, ErrRuntimeTokenRejected) {
t.Fatalf("same runtime after refresh error = %v", err)
}
pending := New(Config{RequireSeed: true, RequireActivation: true})
generation, err := pending.Update(0, "pending")
if err != nil {
t.Fatal(err)
}
waitCtx, waitCancel := context.WithCancel(context.Background())
waitCancel()
if _, err := pending.RefreshRejected(waitCtx, "pending"); !errors.Is(err, context.Canceled) {
t.Fatalf("pending refresh cancellation = %v", err)
}
refreshDone := make(chan error, 1)
go func() {
_, err := pending.RefreshRejected(context.Background(), "pending")
refreshDone <- err
}()
// Let RefreshRejected take the pending credential's changed-channel path
// before activation publishes it.
time.Sleep(10 * time.Millisecond)
if _, err := pending.Activate(generation); err != nil {
t.Fatal(err)
}
select {
case err := <-refreshDone:
if !errors.Is(err, ErrRuntimeTokenRejected) {
t.Fatalf("activated pending refresh error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("pending refresh did not resume")
}
inactive := New(Config{RequireActivation: true})
if _, err := inactive.Update(0, "inactive"); err != nil {
t.Fatal(err)
}
if superseded, err := inactive.ClassifyRejectedAfterRetry("other"); superseded || err != nil {
t.Fatalf("inactive classification = %v, %v", superseded, err)
}
}
@@ -0,0 +1,40 @@
package source
import (
"context"
"io"
"net/http"
"strings"
"testing"
)
func TestCrossPlatformCoverageEventCorePersonalSupersededRetry(t *testing.T) {
source, err := NewPersonal(PersonalConfig{
AccessToken: "stale",
ForceRefreshToken: func(context.Context, string) (string, error) {
return "rotated", nil
},
ClassifyRetryReject: func(token string) (bool, error) {
if token != "rotated" {
t.Fatalf("classified token = %q", token)
}
return true, nil
},
ClientID: "client",
SourceID: "source",
TicketURL: "https://ticket.test",
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusUnauthorized,
Body: io.NopCloser(strings.NewReader("rejected")),
Header: make(http.Header),
}, nil
})},
})
if err != nil {
t.Fatal(err)
}
if _, err := source.fetchTicket(context.Background()); !isRetryablePersonalError(err) {
t.Fatalf("superseded retry error = %v", err)
}
}
+20 -1
View File
@@ -45,6 +45,7 @@ type PersonalConfig struct {
AccessToken string
AccessTokenProvider AccessTokenProvider
ForceRefreshToken ForceRefreshTokenFn
ClassifyRetryReject RetryRejectClassifier
ClientID string
ClientSecret string
SourceID string
@@ -66,6 +67,12 @@ type AccessTokenProvider func(context.Context) (string, error)
// when nil a 401 stays fatal, matching the previous behavior.
type ForceRefreshTokenFn func(ctx context.Context, rejectedToken string) (string, error)
// RetryRejectClassifier classifies a 401 from the one refreshed-token retry.
// superseded means a newer credential is already available and the outer
// reconnect loop should start a fresh attempt; err is a terminal typed
// rejection. A nil callback preserves the historical local OAuth behavior.
type RetryRejectClassifier func(rejectedToken string) (superseded bool, err error)
type PersonalSource struct {
cfg PersonalConfig
machine *Machine
@@ -223,7 +230,19 @@ func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, erro
return nil, refreshErr
}
// Retry once with the freshly rotated token; a second 401 stays fatal.
ticket, _, err = s.fetchTicketAttempt(ctx, refreshed)
var retryStatus int
var retryErr error
ticket, retryStatus, retryErr = s.fetchTicketAttempt(ctx, refreshed)
if retryStatus == http.StatusUnauthorized && s.cfg.ClassifyRetryReject != nil {
superseded, classifyErr := s.cfg.ClassifyRetryReject(refreshed)
if classifyErr != nil {
return nil, classifyErr
}
if superseded {
return nil, retryPersonal(retryErr)
}
}
err = retryErr
}
return ticket, err
}
@@ -14,6 +14,7 @@ import (
"time"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/gorilla/websocket"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/payload"
)
@@ -316,6 +317,46 @@ func TestCrossPlatformCoveragePersonalFetchTicket401RefreshFailureStaysFatal(t *
}
}
func TestCrossPlatformCoveragePersonalRuntimeTokenAtoBSecond401IsTyped(t *testing.T) {
broker := runtimecred.New(runtimecred.Config{})
if _, err := broker.Update(0, "runtime-a"); err != nil {
t.Fatal(err)
}
var attemptTokens []string
src, err := NewPersonal(PersonalConfig{
AccessTokenProvider: broker.Resolve,
ForceRefreshToken: broker.RefreshRejected,
ClassifyRetryReject: broker.ClassifyRejectedAfterRetry,
ClientID: "client",
SourceID: "source",
TicketURL: "https://ticket.test",
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
token := req.Header.Get("x-user-access-token")
attemptTokens = append(attemptTokens, token)
if token == "runtime-a" {
if _, err := broker.Update(1, "runtime-b"); err != nil {
t.Fatalf("rotate runtime credential: %v", err)
}
}
return &http.Response{
StatusCode: http.StatusUnauthorized,
Body: io.NopCloser(strings.NewReader("must-not-surface")),
Header: make(http.Header),
}, nil
})},
})
if err != nil {
t.Fatal(err)
}
_, err = src.fetchTicket(context.Background())
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
t.Fatalf("fetchTicket() error = %v", err)
}
if len(attemptTokens) != 2 || attemptTokens[0] != "runtime-a" || attemptTokens[1] != "runtime-b" {
t.Fatalf("attempt tokens = %v", attemptTokens)
}
}
// brokenBody simulates a response body that fails mid-read, e.g. the server
// closing the connection before the error payload is fully written.
type brokenBody struct{}
+60 -12
View File
@@ -26,16 +26,28 @@ import (
type FrameType string
const (
FrameTypeHello FrameType = "hello" // consume → bus
FrameTypeHelloAck FrameType = "hello_ack" // bus → consume
FrameTypeEvent FrameType = "event" // bus → consume
FrameTypeHeartbeat FrameType = "heartbeat" // bidirectional
FrameTypeSourceState FrameType = "source_state" // bus → consume
FrameTypeBye FrameType = "bye" // bidirectional
FrameTypeStatusReq FrameType = "status_req" // consume/ad-hoc → bus
FrameTypeStatusResp FrameType = "status_resp" // bus → consume/ad-hoc
FrameTypeConsumerStopReq FrameType = "consumer_stop_req" // ad-hoc → bus
FrameTypeConsumerStopResp FrameType = "consumer_stop_resp" // bus → ad-hoc
FrameTypeHello FrameType = "hello" // consume → bus
FrameTypeHelloAck FrameType = "hello_ack" // bus → consume
FrameTypeEvent FrameType = "event" // bus → consume
FrameTypeHeartbeat FrameType = "heartbeat" // bidirectional
FrameTypeSourceState FrameType = "source_state" // bus → consume
FrameTypeBye FrameType = "bye" // bidirectional
FrameTypeStatusReq FrameType = "status_req" // consume/ad-hoc → bus
FrameTypeStatusResp FrameType = "status_resp" // bus → consume/ad-hoc
FrameTypeConsumerStopReq FrameType = "consumer_stop_req" // ad-hoc → bus
FrameTypeConsumerStopResp FrameType = "consumer_stop_resp" // bus → ad-hoc
FrameTypeCredentialUpdate FrameType = "credential_update" // consume → bus
FrameTypeCredentialUpdateAck FrameType = "credential_update_ack" // bus → consume
)
// CredentialMode declares that a consumer needs an additive credential
// negotiation before it can register for events. The zero value preserves the
// original protocol.
type CredentialMode string
const (
CredentialModeRuntimeToken CredentialMode = "runtime_token"
CapabilityRuntimeTokenV1 = "runtime_token_v1"
)
// Hello is the first frame a consumer sends after dialing the bus. The bus
@@ -51,7 +63,8 @@ type Hello struct {
// Role distinguishes a real consumer (registered for events) from an
// ad-hoc tooling connection (status/list/stop). Ad-hoc connections do
// NOT register with the Hub.
Role HelloRole `json:"role,omitempty"`
Role HelloRole `json:"role,omitempty"`
CredentialMode CredentialMode `json:"credential_mode,omitempty"`
}
// HelloRole tags the purpose of a Hello connection.
@@ -76,6 +89,38 @@ type HelloAck struct {
ClientIDSource string `json:"client_id_source"` // auth.CredentialSource string
ClientSecretSource string `json:"client_secret_source"` // auth.CredentialSource string
IdleTimeoutSecs int `json:"idle_timeout_secs,omitempty"` // bus's IdleTimeout for diagnostics
Capabilities []string `json:"capabilities,omitempty"`
// CredentialGeneration is process-local and contains no secret data.
CredentialGeneration uint64 `json:"credential_generation"`
// TerminalReason is a fixed, non-sensitive bus terminal state. A runtime
// client checks it before sending credential material.
TerminalReason string `json:"terminal_reason,omitempty"`
}
// CredentialUpdate installs a host-supplied runtime token into a compatible
// bus after the bus has advertised CapabilityRuntimeTokenV1. Token is carried
// only over the owner-only local IPC connection and must never be logged.
type CredentialUpdate struct {
Type FrameType `json:"type"`
ExpectedGeneration uint64 `json:"expected_generation"`
Token string `json:"token"`
}
const (
CredentialErrorGenerationConflict = "generation_conflict"
CredentialErrorInvalid = "invalid_credential"
CredentialErrorRegistration = "registration_failed"
CredentialErrorRuntimeRejected = "runtime_token_rejected"
CredentialErrorInternal = "internal_error"
)
// CredentialUpdateAck reports the CAS result without echoing any credential.
type CredentialUpdateAck struct {
Type FrameType `json:"type"`
Accepted bool `json:"accepted"`
CredentialGeneration uint64 `json:"credential_generation"`
ErrorCode string `json:"error_code,omitempty"`
Error string `json:"error,omitempty"`
}
// Event wraps one delivered RawEvent for the wire. We keep the payload as
@@ -126,7 +171,10 @@ type Bye struct {
Reason string `json:"reason"`
}
const ByeReasonSubscriptionStopped = "subscription_stopped"
const (
ByeReasonSubscriptionStopped = "subscription_stopped"
ByeReasonRuntimeTokenRejected = "runtime_token_rejected"
)
// ConsumerStopReq asks the bus to close consumers whose exact personal
// subscription IDs match. It is an additive local IPC control operation;
+72 -31
View File
@@ -23,21 +23,23 @@ import (
// to guard against accidental wire-format changes (the JSON field tags
// double as part of the cross-version protocol contract).
func TestFrameType_StableWireValues(t *testing.T) {
func TestCrossPlatformCoverageFrameTypeStableWireValues(t *testing.T) {
// If any of these strings change we've made a protocol-breaking
// change. The test value list is duplicated here on purpose so a
// reviewer renaming a constant is forced to also update the test.
wants := map[FrameType]string{
FrameTypeHello: "hello",
FrameTypeHelloAck: "hello_ack",
FrameTypeEvent: "event",
FrameTypeHeartbeat: "heartbeat",
FrameTypeSourceState: "source_state",
FrameTypeBye: "bye",
FrameTypeStatusReq: "status_req",
FrameTypeStatusResp: "status_resp",
FrameTypeConsumerStopReq: "consumer_stop_req",
FrameTypeConsumerStopResp: "consumer_stop_resp",
FrameTypeHello: "hello",
FrameTypeHelloAck: "hello_ack",
FrameTypeEvent: "event",
FrameTypeHeartbeat: "heartbeat",
FrameTypeSourceState: "source_state",
FrameTypeBye: "bye",
FrameTypeStatusReq: "status_req",
FrameTypeStatusResp: "status_resp",
FrameTypeConsumerStopReq: "consumer_stop_req",
FrameTypeConsumerStopResp: "consumer_stop_resp",
FrameTypeCredentialUpdate: "credential_update",
FrameTypeCredentialUpdateAck: "credential_update_ack",
}
for ft, want := range wants {
if string(ft) != want {
@@ -104,54 +106,91 @@ func roundTrip(t *testing.T, in any, dst any) {
}
}
func TestHello_Roundtrip(t *testing.T) {
func TestCrossPlatformCoverageHelloRoundtrip(t *testing.T) {
in := Hello{
Type: FrameTypeHello,
ConsumerPID: 42,
EventTypes: []string{"im.*", "approval.task"},
Filter: `^im\.`,
Compact: true,
Role: HelloRoleStatus,
Type: FrameTypeHello,
ConsumerPID: 42,
EventTypes: []string{"im.*", "approval.task"},
Filter: `^im\.`,
Compact: true,
Role: HelloRoleStatus,
CredentialMode: CredentialModeRuntimeToken,
}
var out Hello
roundTrip(t, in, &out)
if out.Type != in.Type || out.ConsumerPID != in.ConsumerPID || out.Filter != in.Filter ||
out.Compact != in.Compact || out.Role != in.Role || len(out.EventTypes) != len(in.EventTypes) {
out.Compact != in.Compact || out.Role != in.Role || out.CredentialMode != in.CredentialMode || len(out.EventTypes) != len(in.EventTypes) {
t.Fatalf("roundtrip mismatch: %+v != %+v", out, in)
}
}
func TestHello_OmitemptyForDefaults(t *testing.T) {
func TestCrossPlatformCoverageHelloOmitemptyForDefaults(t *testing.T) {
// Default values should NOT appear in the wire form so old/new readers
// stay tolerant of each other (each new field comes in with its
// zero value by default).
in := Hello{Type: FrameTypeHello, ConsumerPID: 1}
b, _ := json.Marshal(in)
s := string(b)
for _, k := range []string{`"event_types"`, `"filter"`, `"compact"`, `"role"`} {
for _, k := range []string{`"event_types"`, `"filter"`, `"compact"`, `"role"`, `"credential_mode"`} {
if strings.Contains(s, k) {
t.Errorf("zero-value field %s leaked into wire form: %s", k, s)
}
}
}
func TestHelloAck_Roundtrip(t *testing.T) {
func TestCrossPlatformCoverageHelloAckRoundtrip(t *testing.T) {
in := HelloAck{
Type: FrameTypeHelloAck,
BusPID: 12345,
SourceState: "connected",
StateSource: "inferred",
ClientIDSource: "env",
ClientSecretSource: "env",
IdleTimeoutSecs: 300,
Type: FrameTypeHelloAck,
BusPID: 12345,
SourceState: "connected",
StateSource: "inferred",
ClientIDSource: "env",
ClientSecretSource: "env",
IdleTimeoutSecs: 300,
Capabilities: []string{CapabilityRuntimeTokenV1},
CredentialGeneration: 7,
}
var out HelloAck
roundTrip(t, in, &out)
if out != in {
if out.Type != in.Type || out.BusPID != in.BusPID || out.SourceState != in.SourceState ||
out.StateSource != in.StateSource || out.ClientIDSource != in.ClientIDSource ||
out.ClientSecretSource != in.ClientSecretSource || out.IdleTimeoutSecs != in.IdleTimeoutSecs ||
out.CredentialGeneration != in.CredentialGeneration || strings.Join(out.Capabilities, ",") != CapabilityRuntimeTokenV1 {
t.Fatalf("HelloAck roundtrip: %+v != %+v", out, in)
}
}
func TestCrossPlatformCoverageCredentialFramesRoundtripWithoutTokenInAck(t *testing.T) {
update := CredentialUpdate{
Type: FrameTypeCredentialUpdate,
ExpectedGeneration: 4,
Token: "canary-secret",
}
var decodedUpdate CredentialUpdate
roundTrip(t, update, &decodedUpdate)
if decodedUpdate != update {
t.Fatal("CredentialUpdate roundtrip mismatch")
}
ack := CredentialUpdateAck{
Type: FrameTypeCredentialUpdateAck,
Accepted: true,
CredentialGeneration: 5,
}
var decodedAck CredentialUpdateAck
roundTrip(t, ack, &decodedAck)
if decodedAck != ack {
t.Fatalf("CredentialUpdateAck roundtrip = %#v", decodedAck)
}
b, err := json.Marshal(ack)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), update.Token) {
t.Fatal("credential acknowledgement contained token")
}
}
func TestEvent_Roundtrip(t *testing.T) {
in := Event{
Type: FrameTypeEvent,
@@ -245,7 +284,7 @@ func TestStatusResp_Roundtrip(t *testing.T) {
// PeekType is used by the daemon to dispatch incoming frames before
// fully decoding into the typed struct. Behaviour at boundaries matters.
func TestPeekType_EachFrameVariant(t *testing.T) {
func TestCrossPlatformCoveragePeekTypeEachFrameVariant(t *testing.T) {
cases := []struct {
v any
typ FrameType
@@ -258,6 +297,8 @@ func TestPeekType_EachFrameVariant(t *testing.T) {
{Bye{Type: FrameTypeBye, Reason: "x"}, FrameTypeBye},
{StatusReq{Type: FrameTypeStatusReq}, FrameTypeStatusReq},
{StatusResp{Type: FrameTypeStatusResp}, FrameTypeStatusResp},
{CredentialUpdate{Type: FrameTypeCredentialUpdate}, FrameTypeCredentialUpdate},
{CredentialUpdateAck{Type: FrameTypeCredentialUpdateAck}, FrameTypeCredentialUpdateAck},
}
for _, c := range cases {
b, _ := json.Marshal(c.v)
+173 -44
View File
@@ -27,6 +27,79 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func callProjectedChatMessages(cmd *cobra.Command, toolName string, args map[string]any, search bool) error {
if deps.Caller.DryRun() {
return callMCPToolOnServer("chat", toolName, args)
}
text, err := callMCPToolReturnTextOnServer(cmd.Context(), "chat", toolName, args)
if err != nil {
var cliErr *CLIError
if errors.As(err, &cliErr) && cliErr.Operation == "" {
withOperation := *cliErr
withOperation.Operation = "chat/" + toolName
return &withOperation
}
return err
}
if strings.TrimSpace(text) == "" {
return apperrors.NewAPI("MCP read tool returned no non-empty text content",
apperrors.WithOperation("chat/"+toolName),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("response_validation"),
apperrors.WithRetryable(true),
apperrors.WithReason("empty_tool_response"),
)
}
data := map[string]any{}
if err := unmarshalJSONUseNumber(text, &data); err != nil {
deps.Out.PrintRaw(text)
return nil
}
return writeCommandPayload(cmd, projectChatMessagesPayload(data, search))
}
func projectChatMessagesPayload(data map[string]any, search bool) map[string]any {
items := chatmsg.ListMessageItems(data)
if search {
items = chatmsg.SearchItems(data)
}
messages := make([]map[string]any, 0, len(items))
for _, item := range items {
projected := make(map[string]any, len(item)+8)
for key, value := range item {
projected[key] = value
}
for key, value := range chatmsg.ProjectMessageV1(item, true) {
if key == "messageId" || key == "text" {
projected[key] = value
continue
}
if _, exists := projected[key]; !exists {
projected[key] = value
}
}
if value, exists := item["openMessageId"]; exists {
projected["openMessageId"] = value
} else if value, exists := projected["messageId"]; exists {
projected["openMessageId"] = value
}
if value, exists := item["content"]; exists {
projected["content"] = value
} else if value, exists := projected["text"]; exists {
projected["content"] = value
}
messages = append(messages, projected)
}
payload := make(map[string]any, len(data)+1)
for key, value := range data {
payload[key] = value
}
payload["messages"] = messages
return payload
}
func resolveMessageForward(cmd *cobra.Command, defaultForward bool) (bool, error) {
forwardStr, _ := cmd.Flags().GetString("forward")
forward := forwardStr != "false"
@@ -137,13 +210,14 @@ func runConversationScopedPagedMessageSearch(
if err != nil {
return err
}
return runConversationScopedMessageSearch(
return runConversationScopedMessageSearchWithProjector(
cmd,
cfg.ServerID,
cfg.ToolName,
scopeParam,
toolArgs,
conversationIDs,
cfg.ProjectResult,
)
}
@@ -152,6 +226,24 @@ func runConversationScopedMessageSearch(
serverID, toolName, scopeParam string,
toolArgs map[string]any,
conversationIDs []string,
) error {
return runConversationScopedMessageSearchWithProjector(
cmd,
serverID,
toolName,
scopeParam,
toolArgs,
conversationIDs,
nil,
)
}
func runConversationScopedMessageSearchWithProjector(
cmd *cobra.Command,
serverID, toolName, scopeParam string,
toolArgs map[string]any,
conversationIDs []string,
projectResult func(map[string]any) map[string]any,
) error {
conversationIDs = uniqueNonEmptyStrings(conversationIDs)
if len(conversationIDs) == 0 {
@@ -303,6 +395,9 @@ func runConversationScopedMessageSearch(
}
payload["paging"] = paging
}
if projectResult != nil {
payload = projectResult(payload)
}
return writeCommandPayload(cmd, payload)
}
@@ -670,6 +765,17 @@ func pagedChatConversationMessagesConfig(toolName string, build func(*cobra.Comm
return cfg
}
func pagedProjectedChatSearchConfig(cmd *cobra.Command, toolName string, build func(*cobra.Command) (map[string]any, error)) PagedMCPCommandConfig {
cfg := pagedChatConversationMessagesConfig(toolName, build)
cfg.Fallback = func(args map[string]any) error {
return callProjectedChatMessages(cmd, toolName, args, true)
}
cfg.ProjectResult = func(payload map[string]any) map[string]any {
return projectChatMessagesPayload(payload, true)
}
return cfg
}
func pagedChatConversationMessagesOnServerConfig(serverID, toolName string, build func(*cobra.Command) (map[string]any, error)) PagedMCPCommandConfig {
cfg := pagedChatConversationMessagesConfig(toolName, build)
cfg.ServerID = serverID
@@ -1492,6 +1598,36 @@ func buildChatCrossOrgDataAuthArgs(cmd *cobra.Command) (map[string]any, error) {
return toolArgs, nil
}
func buildChatGroupShareInviteArgs(cmd *cobra.Command) (map[string]any, error) {
if err := validateRequiredFlags(cmd, "source"); err != nil {
return nil, err
}
target, _ := cmd.Flags().GetString("target")
receiver, _ := cmd.Flags().GetString("receiver")
if target == "" && receiver == "" {
return nil, fmt.Errorf("--target or --receiver is required")
}
if target != "" && receiver != "" {
return nil, fmt.Errorf("--target and --receiver are mutually exclusive")
}
toolArgs := map[string]any{
"sourceOpenConversationId": mustGetFlag(cmd, "source"),
}
if target != "" {
toolArgs["targetOpenConversationId"] = target
}
if receiver != "" {
toolArgs["receiverOpenDingTalkId"] = receiver
}
if v, _ := cmd.Flags().GetInt64("expires-seconds"); v > 0 || cmd.Flags().Changed("expires-seconds") {
toolArgs["expiresSeconds"] = v
}
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
toolArgs["uuid"] = v
}
return toolArgs, nil
}
func appendChatChmodParams(cmd *cobra.Command, toolArgs map[string]any) error {
conversationID, _ := cmd.Flags().GetString("conversation-id")
openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id")
@@ -1974,11 +2110,11 @@ func newChatCommand() *cobra.Command {
Parameters: []contract.ParamDecl{
{Name: "agentCode", Property: "agentCode", Required: boolPtr(false)},
{Name: "conversation-id", Property: "grantParams.openCid", Required: boolPtr(false)},
{Name: "grant-type", Property: "grantType", Required: boolPtr(false)},
{Name: "grant-type", Property: "grantType", Required: boolPtr(false), Enum: []string{"once", "session", "timed", "permanent"}},
{Name: "open-dingtalk-id", Property: "grantParams.openDingTalkId", Required: boolPtr(false)},
{Name: "permParam", Property: "grantParams", Required: boolPtr(false)},
{Name: "session-id", Property: "sessionId", Required: boolPtr(false)},
{Name: "ttl", Property: "ttl", Required: boolPtr(false)},
{Name: "session-id", Property: "sessionId", Required: boolPtr(false), RequiredWhen: "grant-type is session"},
{Name: "ttl", Property: "ttl", Required: boolPtr(false), RequiredWhen: "grant-type is timed"},
{Name: "user", Property: "grantParams.userId", Required: boolPtr(false)},
},
},
@@ -2017,10 +2153,15 @@ func newChatCommand() *cobra.Command {
chatDataAuthCrossOrgCmd.Flags().String("grant-type", "timed", "授权策略: once|session|timed|permanent")
chatDataAuthCrossOrgCmd.Flags().String("ttl", "24h", "timed 授权有效期,如 1h/4h/24h/7d")
chatDataAuthCrossOrgCmd.Flags().String("session-id", "", "session 授权的会话标识")
chatDataAuthCrossOrgCmd.Flags().BoolP("yes", "y", false, "确认执行跨组织 chat 数据授权")
DeclareLeafMetadata(chatDataAuthCrossOrgCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
Validate: func(cmd *cobra.Command, args []string) error {
_, err := buildChatCrossOrgDataAuthArgs(cmd)
return err
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
@@ -2045,10 +2186,10 @@ func newChatCommand() *cobra.Command {
Parameters: []contract.ParamDecl{
{Name: "agentCode", Property: "agentCode", Required: boolPtr(false)},
{Name: "all", Property: "grantParams.targetOrgId", Required: boolPtr(false)},
{Name: "grant-type", Property: "grantType", Required: boolPtr(false)},
{Name: "session-id", Property: "sessionId", Required: boolPtr(false)},
{Name: "grant-type", Property: "grantType", Required: boolPtr(false), Enum: []string{"once", "session", "timed", "permanent"}},
{Name: "session-id", Property: "sessionId", Required: boolPtr(false), RequiredWhen: "grant-type is session"},
{Name: "target-org-id", Property: "grantParams.targetOrgId", Required: boolPtr(false)},
{Name: "ttl", Property: "ttl", Required: boolPtr(false)},
{Name: "ttl", Property: "ttl", Required: boolPtr(false), RequiredWhen: "grant-type is timed"},
},
},
})
@@ -2464,11 +2605,12 @@ func newChatCommand() *cobra.Command {
chatMessageListCmd := &cobra.Command{
Use: "list",
Short: "拉取会话消息内容",
Long: `拉取指定群聊或单聊的会话消息内容。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`,
Long: `拉取指定群聊或单聊的会话消息内容。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`,
Example: ` dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
dws chat message list --user <userId> --time "2025-03-01 00:00:00" --limit 50
dws chat message list --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 50
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --direction older
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --jq '.messages[] | {messageId, text}'
# 查询群 ID: dws chat search --query "群名"
# 查询 userId: dws contact user search --query "姓名"`,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -2512,7 +2654,7 @@ func newChatCommand() *cobra.Command {
if v := chatIntFlagOrFallback(cmd, "limit", "size"); v > 0 {
toolArgs["limit"] = v
}
return callMCPTool("list_conversation_message_v2", toolArgs)
return callProjectedChatMessages(cmd, "list_conversation_message_v2", toolArgs, false)
}
toolArgs := map[string]any{
"time": timeVal,
@@ -2526,7 +2668,7 @@ func newChatCommand() *cobra.Command {
if v := chatIntFlagOrFallback(cmd, "limit", "size"); v > 0 {
toolArgs["limit"] = v
}
return callMCPTool("list_individual_chat_message", toolArgs)
return callProjectedChatMessages(cmd, "list_individual_chat_message", toolArgs, false)
},
}
DeclareLeafMetadata(chatMessageListCmd, LeafSpec{
@@ -2552,7 +2694,10 @@ func newChatCommand() *cobra.Command {
AgentSummary: "分页读取指定会话消息及其引用上下文",
UseWhen: []string{"用户明确指定某个会话,并要读取消息或追溯引用回复中的原消息上下文时"},
AvoidWhen: []string{"跨全部会话按时间查询时使用 chat message list-all"},
Examples: []string{"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50"},
Examples: []string{
"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50",
"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50 --jq '.messages[] | {messageId, text}'",
},
},
Parameters: []contract.ParamDecl{
{Name: "direction", Property: "forward"},
@@ -3625,17 +3770,18 @@ func newChatCommand() *cobra.Command {
chatMessageSearchCmd := &cobra.Command{
Use: "search",
Short: "按关键词搜索消息",
Long: `在当前用户的会话中按关键词搜索消息。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。未指定会话时默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持默认行为。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`,
Long: `在当前用户的会话中按关键词搜索消息。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId、content 和原始 result。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。未指定会话时默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持默认行为。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`,
Example: ` dws chat message search --query "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0
dws chat message search --query "codereview" --group <openconversation_id> --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0
dws chat message search --query "链接" --start "2026-04-15T00:00:00+08:00" --end "2026-04-16T00:00:00+08:00" --limit 100 --cursor <nextCursor>
dws chat message search --query "发布计划" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --page-all --max-items 300 --page-delay 0
# 查询群 ID: dws chat search --query "群名"`,
dws chat message search --query "发布计划" --start "2026-07-01T00:00:00+08:00" --end "2026-07-10T00:00:00+08:00" --jq '.messages[] | {messageId, text}'
# 查询群 ID: dws chat search --query "群名"`,
RunE: func(cmd *cobra.Command, args []string) error {
groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat")
return runConversationScopedPagedMessageSearch(
cmd,
pagedChatConversationMessagesConfig("search_messages_by_keyword", chatMessageSearchArgs),
pagedProjectedChatSearchConfig(cmd, "search_messages_by_keyword", chatMessageSearchArgs),
"openConversationId",
[]string{groupID},
)
@@ -3666,7 +3812,7 @@ func newChatCommand() *cobra.Command {
AvoidWhen: []string{"需要多会话、发送者或 @维度组合时使用 search-advanced"},
Examples: []string{
"dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\"",
"dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\" --page-all --max-items 300",
"dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\" --page-all --max-items 300 --jq '.messages[] | {messageId, text}'",
},
},
Parameters: append([]contract.ParamDecl{
@@ -8227,12 +8373,16 @@ status 可选值:
if err != nil {
return fmt.Errorf("--record-id must be a valid integer: %w", err)
}
status := mustGetFlag(cmd, "status")
if status != "AuditApprove" && status != "AuditDelete" {
return fmt.Errorf("unsupported audit status %q, must be one of: AuditApprove, AuditDelete", status)
}
toolArgs := map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"applyRecordId": recordID,
"applicantUid": mustGetFlag(cmd, "applicant"),
"inviterUid": mustGetFlag(cmd, "inviter"),
"status": mustGetFlag(cmd, "status"),
"status": status,
}
if v, _ := cmd.Flags().GetString("description"); v != "" {
toolArgs["auditDescription"] = v
@@ -8282,7 +8432,7 @@ status 可选值:
{Name: "group", Property: "openConversationId", Required: boolPtr(true)},
{Name: "inviter", Property: "inviterUid", Required: boolPtr(true)},
{Name: "record-id", Property: "applyRecordId", Required: boolPtr(true), InterfaceType: "integer"},
{Name: "status", Property: "status", Required: boolPtr(true), Enum: []string{"AuditApprove", "AuditDelete", "AuditIgnore", "AuditRefuse", "AuditBlock"}},
{Name: "status", Property: "status", Required: boolPtr(true), Enum: []string{"AuditApprove", "AuditDelete"}},
},
},
})
@@ -9386,32 +9536,10 @@ status 可选值:
dws chat group share-invite --source <openConversationId> --target <openConversationId> --expires-seconds 86400
# 查询群 ID: dws chat search --query "群名"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "source"); err != nil {
toolArgs, err := buildChatGroupShareInviteArgs(cmd)
if err != nil {
return err
}
target, _ := cmd.Flags().GetString("target")
receiver, _ := cmd.Flags().GetString("receiver")
if target == "" && receiver == "" {
return fmt.Errorf("--target or --receiver is required")
}
if target != "" && receiver != "" {
return fmt.Errorf("--target and --receiver are mutually exclusive")
}
toolArgs := map[string]any{
"sourceOpenConversationId": mustGetFlag(cmd, "source"),
}
if target != "" {
toolArgs["targetOpenConversationId"] = target
}
if receiver != "" {
toolArgs["receiverOpenDingTalkId"] = receiver
}
if v, _ := cmd.Flags().GetInt64("expires-seconds"); v > 0 || cmd.Flags().Changed("expires-seconds") {
toolArgs["expiresSeconds"] = v
}
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
toolArgs["uuid"] = v
}
return callMCPToolOnServer("im", "share_group_invite_url", toolArgs)
},
}
@@ -9421,10 +9549,11 @@ status 可选值:
chatGroupShareInviteCmd.Flags().String("receiver", "", "接收分享消息的单聊用户 openDingTalkId(与 --target 二选一)")
chatGroupShareInviteCmd.Flags().Int64("expires-seconds", 0, "链接有效期(秒),0 表示永久有效,不传使用服务端默认值")
chatGroupShareInviteCmd.Flags().String("uuid", "", "消息幂等键(可选)")
chatGroupShareInviteCmd.Flags().BoolP("yes", "y", false, "确认分享群邀请链接")
DeclareLeafMetadata(chatGroupShareInviteCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
@@ -108,7 +108,7 @@ func TestChatMessagePaginationDefaultSinglePageUnchanged(t *testing.T) {
{
name: "search",
args: []string{"message", "search", "--query", "发布", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
server: "",
server: "chat",
tool: "search_messages_by_keyword",
want: map[string]any{"keyword": "发布", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 100, "cursor": "0"},
},
@@ -219,6 +219,21 @@ func TestChatMessagePaginationPageAllAggregatesSevenCommands(t *testing.T) {
if len(items) != 1 || len(messages) != 2 {
t.Fatalf("conversation items = %#v", items)
}
if tt.name == "search" {
projected, ok := got["messages"].([]any)
if !ok || len(projected) != 2 {
t.Fatalf("projected messages = %#v", got["messages"])
}
for i, wantID := range []string{"m1", "m2"} {
message, ok := projected[i].(map[string]any)
if !ok || message["messageId"] != wantID || message["openMessageId"] != wantID {
t.Fatalf("projected message %d = %#v", i, projected[i])
}
if _, exists := message["text"]; !exists {
t.Fatalf("projected message %d missing text: %#v", i, message)
}
}
}
} else if len(items) != 2 {
t.Fatalf("items = %#v", items)
}
+17 -3
View File
@@ -701,9 +701,7 @@ func executeChatChangedContract(t *testing.T, caller *chatChangedContractCaller,
InitDeps(caller)
deps.Out.w = io.Discard
cmd := newChatCommand()
if cmd.PersistentFlags().Lookup("yes") == nil {
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
}
installExampleGlobalFlags(cmd)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(append(append([]string(nil), args...), "--yes"))
@@ -746,6 +744,22 @@ func TestCrossPlatformCoverageChatAuditUsesUserIDs(t *testing.T) {
}
}
func TestCrossPlatformCoverageChatAuditRejectsUnsupportedStatus(t *testing.T) {
caller := &chatChangedContractCaller{}
err := executeChatChangedContract(t, caller,
"group", "audit-join-validation",
"--group", "cid-1", "--record-id", "123", "--applicant", "user-a", "--inviter", "user-b", "--status", "AuditRefuse")
if err == nil {
t.Fatal("expected unsupported audit status error")
}
if !strings.Contains(err.Error(), `unsupported audit status "AuditRefuse"`) {
t.Fatalf("error = %v, want unsupported status", err)
}
if len(caller.calls) != 0 {
t.Fatalf("unsupported status must not call MCP: %#v", caller.calls)
}
}
func TestCrossPlatformCoverageChatSendResolvesUserBeforeDispatch(t *testing.T) {
caller := &chatChangedContractCaller{resolveUsers: true}
err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello")
+46 -1
View File
@@ -14,14 +14,59 @@
package helpers
import (
"io"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// resolveCommandFormat 是 helpers 侧的 format 解析桥接(B44,WS1 改动点4):
// 从 cmd flags 统一解析输出 format——显式非空 --format 恒优先,其次 --json
// 布尔简写,两者皆无落 fallback json(轮11-DEV 裁决函数
// output.ResolveFormatWithJSONShorthand,含未知值归一化降级)。与主漏斗
// callMCPToolInternalOpts 的 output.ParseFormat(caller 字符串路径,轮8 A3)
// 共用同一 normalizeFormat 归一化规则——cmd 路径与 caller 路径收敛于同一
// 单一事实源,不重造第二套解析。nil cmd 返回 fallback(不 panic)。
func resolveCommandFormat(cmd *cobra.Command) output.Format {
return output.ResolveFormatWithJSONShorthand(cmd, output.FormatJSON)
}
// writeCommandPayload 按当前 format 分发命令载荷(B57,WS1 改动点4):
// format 经 resolveCommandFormat(B44 桥接)解析,替换原固定 FormatJSON 兜底;
// --fields/--jq 全局过滤同路联动;数据出口走 cmd.OutOrStdout()(可被测试
// 重定向,不硬编码 os.Stdout)。nil cmd 按 json 渲染进 io.Discard(与
// output.WriteCommandPayload 的容错口径一致)。
func writeCommandPayload(cmd *cobra.Command, payload any) error {
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
format := resolveCommandFormat(cmd)
if cmd == nil {
return output.Write(io.Discard, format, payload)
}
return output.WriteFiltered(cmd.OutOrStdout(), format, payload, output.ResolveFields(cmd), output.ResolveJQ(cmd))
}
// writeEnvelope 是 helpers 侧的统一信封装配出口(B58,WS1 改动点2):信封
// 渲染先进 buffer 再写 cmd 流(buffer-first 由 internal/output 承载),按
// outcome 分流:
//
// - success / pending / partial_failure → 数据通道:经 output.WriteEnvelope
// 走完整 format 分发矩阵(含未知 format 降级 + stderr warning,AC-09);
// - failure(含 nil 信封降级)→ 错误通道:经 Emitter 落 cmd.ErrOrStderr(),
// stdout 严格零字节(AC-11),失败信封恒完整 JSON 且绕过 format/jq/fields
// (轮8裁决⑪)。
//
// 本函数只做装配分流,不重定义信封类型或渲染规则。
func writeEnvelope(cmd *cobra.Command, env *output.Envelope) error {
if env != nil && env.Outcome != output.OutcomeFailure {
return output.WriteEnvelope(cmd, env, output.FormatJSON)
}
w, errW := io.Writer(io.Discard), io.Writer(io.Discard)
if cmd != nil {
w = cmd.OutOrStdout()
errW = cmd.ErrOrStderr()
}
return output.NewEmitter(w, errW, output.FormatJSON, "", "").Emit(env)
}
func preferLegacyLeaf(cmd *cobra.Command) {
+188 -21
View File
@@ -31,6 +31,7 @@ import (
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/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/spf13/cobra"
@@ -334,11 +335,22 @@ func startDaemon(cmd *cobra.Command, dirKey, clientID, unifiedAppID, channel, no
// Release the child so the parent can exit without leaving a zombie.
pid := child.Process.Pid
_ = child.Process.Release()
// The foreground/daemon root remains one streaming legacy command in this
// release. It cannot adopt a terminal result until the modes are split.
writeConnectDaemonStarted(cmd.OutOrStdout(), pid, logPath, clientID, dirKey)
return nil
}
// connectDaemonStartedResult 是 `dev connect --daemon` 父进程的结果 DTO
// (队列 B110):pid/日志路径等机器事实进成功信封的 data 层。
type connectDaemonStartedResult struct {
Status string `json:"status"`
Pid int `json:"pid"`
LogPath string `json:"logPath,omitempty"`
DirKey string `json:"dirKey,omitempty"`
ClientID string `json:"clientId,omitempty"`
}
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)
@@ -587,21 +599,32 @@ func supervisedLabel(supervised bool) string {
return "none (foreground or external)"
}
// connectStopResult 是 `dev connect stop` 的结果 DTO(统一输出 dev 域试点,
// 队列 B112):进成功信封的 data 层。status 是机器可分支的终态枚举。
type connectStopResult struct {
Status string `json:"status"`
PreviewKind string `json:"preview_kind,omitempty"`
Pid int `json:"pid,omitempty"`
Detail string `json:"detail,omitempty"`
}
// daemonStop gracefully stops the connector daemon: SIGTERM the supervisor (it
// forwards to the worker, which releases the lock and Stream connection), poll
// until it exits, escalate to SIGKILL on timeout, and clean up the pid file.
func daemonStop(w io.Writer, dirKey string) error {
// 人读进度行写 w(调用方传 stderr);机器可消费的结果作为返回值由 RunE 层
// 包进统一信封写 stdout(契约规范 §5.1:stdout 只承载数据)。
func daemonStopResult(w io.Writer, dirKey string) (*connectStopResult, error) {
dir, err := connectDaemonDir(dirKey)
if err != nil {
return apperrors.NewInternal("resolve daemon dir: " + err.Error())
return nil, apperrors.NewInternal("resolve daemon dir: " + err.Error())
}
st, err := readDaemonState(dir)
if err != nil {
return apperrors.NewInternal(err.Error())
return nil, apperrors.NewInternal(err.Error())
}
if st == nil || st.Pid <= 0 {
fmt.Fprintf(w, "connect daemon: not running (nothing to stop)\n")
return nil
return &connectStopResult{Status: "not_running", Detail: "no daemon record found"}, nil
}
if !daemonProcessAlive(st.Pid) {
_ = os.Remove(daemonPidPath(dir))
@@ -625,17 +648,17 @@ func daemonStop(w io.Writer, dirKey string) error {
}
}
fmt.Fprintf(w, "connect daemon: orphan worker stopped (pid %d)\n", hb.Pid)
return nil
return &connectStopResult{Status: "orphan_worker_stopped", Pid: hb.Pid, Detail: "supervisor was dead; orphan worker stopped"}, nil
}
fmt.Fprintf(w, "connect daemon: was not running (cleaned up stale pid file for pid %d)\n", st.Pid)
return nil
return &connectStopResult{Status: "cleaned_stale", Pid: st.Pid, Detail: "cleaned up stale pid file"}, nil
}
proc, err := daemonFindProcess(st.Pid)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("find daemon process %d: %v", st.Pid, err))
return nil, apperrors.NewInternal(fmt.Sprintf("find daemon process %d: %v", st.Pid, err))
}
if err := daemonSignalProcess(proc, syscall.SIGTERM); err != nil {
return apperrors.NewInternal(fmt.Sprintf("signal daemon %d: %v", st.Pid, err))
return nil, apperrors.NewInternal(fmt.Sprintf("signal daemon %d: %v", st.Pid, err))
}
fmt.Fprintf(w, "sent SIGTERM to connect daemon (pid %d), waiting for graceful stop...\n", st.Pid)
@@ -644,7 +667,7 @@ func daemonStop(w io.Writer, dirKey string) error {
if !daemonProcessAlive(st.Pid) {
_ = os.Remove(daemonPidPath(dir))
fmt.Fprintf(w, "connect daemon stopped (pid %d)\n", st.Pid)
return nil
return &connectStopResult{Status: "stopped", Pid: st.Pid}, nil
}
helperSleep(200 * time.Millisecond)
}
@@ -653,7 +676,15 @@ func daemonStop(w io.Writer, dirKey string) error {
helperSleep(200 * time.Millisecond)
_ = os.Remove(daemonPidPath(dir))
fmt.Fprintf(w, "connect daemon did not stop in %s; sent SIGKILL (pid %d)\n", daemonStopTimeout, st.Pid)
return nil
return &connectStopResult{Status: "force_killed", Pid: st.Pid, Detail: "graceful stop timed out; SIGKILL sent"}, nil
}
// daemonStop keeps the established helper contract for legacy callers and
// platform coverage. Unified-result commands use daemonStopResult when they
// need the typed terminal payload.
func daemonStop(w io.Writer, dirKey string) error {
_, err := daemonStopResult(w, dirKey)
return err
}
// newDevAppRobotConnectStatusCommand implements `dws devapp robot connect
@@ -680,6 +711,9 @@ func newDevAppRobotConnectStatusCommand() *cobra.Command {
cmd.Flags().Bool("json", false, "以 JSON 输出健康报告(供 launchd/systemd/pm2/cron 判断是否重启)")
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
// Preserve the published Schema safety tuple during the unified-result
// pilot. Correcting this historical classification is a separate,
// explicitly reviewed interface change.
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
@@ -692,6 +726,10 @@ func newDevAppRobotConnectStatusCommand() *cobra.Command {
PrimaryCLIPath: "dev connect status",
},
Description: "查看后台连接器守护进程状态",
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"本地连接器守护进程状态","properties":{"state":{"type":"string","description":"归一化健康状态","enum":["healthy","degraded","down","not_running"]},"pid":{"type":"integer","description":"守护进程 PID;未运行时可能缺省"},"channel":{"type":"string","description":"连接器使用的通道"},"clientId":{"type":"string","description":"连接器客户端 ID"},"unifiedAppId":{"type":"string","description":"关联的统一应用 ID"},"supervised":{"type":"boolean","description":"是否由本地 supervisor 管理"},"lastError":{"type":"string","description":"最近一次连接错误;健康时可能缺省"}},"required":["state","supervised"],"additionalProperties":true}`),
},
Interface: &contract.InterfaceSpec{
Mode: "local",
Availability: "available",
@@ -722,17 +760,40 @@ func newDevAppRobotConnectStopCommand() *cobra.Command {
if err != nil {
return err
}
return daemonStop(cmd.OutOrStdout(), dirKey)
if commandDryRun(cmd) {
preview := &connectStopResult{
Status: "preview",
PreviewKind: contract.DryRunPreviewPlan,
Detail: "would send SIGTERM to the selected daemon and escalate to SIGKILL only after the graceful timeout",
}
env := &output.Envelope{OK: true, Outcome: output.OutcomeSuccess, DryRun: true, Data: preview}
return writeDevRolloutResult(cmd, output.Success(preview, output.WithDryRun()), env, output.FormatJSON)
}
result, err := daemonStopResult(cmd.ErrOrStderr(), dirKey)
if err != nil {
return err
}
// 统一输出 dev 域试点(队列 B112):结果信封化(ok:true + data)。
env := &output.Envelope{OK: true, Outcome: output.OutcomeSuccess, Data: result}
return writeDevRolloutResult(cmd, output.Success(result), env, output.FormatJSON)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("robot-client-id", "", "机器人 clientId(定位守护进程)")
cmd.Flags().String("unified-app-id", "", "统一应用 ID(当未用 clientId 起守护进程时定位)")
DeclareLeafMetadata(cmd, LeafSpec{
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{
// Preserve the published Schema safety tuple during the unified-result
// pilot. The stricter classification belongs in a separate interface
// migration rather than this framework-only compatibility rollout.
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Validate: func(c *cobra.Command, _ []string) error {
_, err := connectDaemonDirKeyFromFlags(c)
return err
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "dev",
@@ -742,6 +803,7 @@ func newDevAppRobotConnectStopCommand() *cobra.Command {
PrimaryCLIPath: "dev connect stop",
},
Description: "优雅停止后台连接器守护进程",
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewPlan, RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "local",
Availability: "available",
@@ -751,13 +813,66 @@ func newDevAppRobotConnectStopCommand() *cobra.Command {
AgentSummary: "优雅停止后台连接器守护进程",
UseWhen: []string{"用户明确要求停止本地 Stream/连接器守护进程"},
AvoidWhen: []string{"只想查看状态时用 dev connect status"},
Examples: []string{"dws dev connect stop --unified-app-id <unifiedAppId>"},
Examples: []string{"dws dev connect stop --unified-app-id <unifiedAppId>", "dws dev connect stop --unified-app-id <unifiedAppId> --dry-run --format json"},
},
},
})
return cmd
}
// connectRestartResult 是 `dev connect restart` 的结果 DTO(队列 B113):
// 进成功信封的 data 层。pid 是重启后新守护进程的 pid(从刷新后的
// daemon-state.json 回读;子进程自身的结果信封由本命令信封取代,不转发)。
type connectRestartResult struct {
Status string `json:"status"`
PreviewKind string `json:"preview_kind,omitempty"`
Pid int `json:"pid,omitempty"`
DirKey string `json:"dirKey,omitempty"`
UnifiedAppID string `json:"unifiedAppId,omitempty"`
Channel string `json:"channel,omitempty"`
Command string `json:"command,omitempty"`
}
func validateConnectRestart(cmd *cobra.Command) error {
dirKey, err := connectDaemonDirKeyFromFlags(cmd)
if err != nil {
return err
}
// An explicit unified app ID is sufficient to produce a side-effect-free
// restart plan. Runtime execution still requires persisted daemon state.
if commandDryRun(cmd) && devAppStringFlag(cmd, "unified-app-id") != "" {
return nil
}
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` 首次启动")
}
if st.UnifiedAppID == "" {
return apperrors.NewValidation("该连接器未持久化 unifiedAppId(可能是用 --robot-client-id/--robot-client-secret 直接启动的,无法安全重启:clientSecret 不落盘);请停掉后用 `dws dev connect --daemon --unified-app-id <uappid>` 重新启动,之后 restart 就能自动从 credentials get 拉密钥、命令行不出现 secret")
}
return nil
}
func writeConnectRestartPreview(cmd *cobra.Command, dirKey, unifiedAppID, channel string) error {
preview := &connectRestartResult{
Status: "preview",
PreviewKind: contract.DryRunPreviewPlan,
DirKey: dirKey,
UnifiedAppID: unifiedAppID,
Channel: channel,
Command: fmt.Sprintf("dws dev connect --daemon --unified-app-id %s", unifiedAppID),
}
env := &output.Envelope{OK: true, Outcome: output.OutcomeSuccess, DryRun: true, Data: preview}
return writeDevRolloutResult(cmd, output.Success(preview, output.WithDryRun()), env, output.FormatJSON)
}
// 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
@@ -773,6 +888,7 @@ func newDevAppRobotConnectRestartCommand() *cobra.Command {
if err != nil {
return err
}
explicitUnifiedAppID := devAppStringFlag(cmd, "unified-app-id")
dir, err := connectDaemonDir(dirKey)
if err != nil {
return apperrors.NewInternal("resolve daemon dir: " + err.Error())
@@ -781,6 +897,9 @@ func newDevAppRobotConnectRestartCommand() *cobra.Command {
if err != nil {
return apperrors.NewInternal(err.Error())
}
if st == nil && commandDryRun(cmd) && explicitUnifiedAppID != "" {
return writeConnectRestartPreview(cmd, dirKey, explicitUnifiedAppID, "")
}
if st == nil {
return apperrors.NewValidation("未找到连接器记录(没有 daemon.pid);请用 `dws dev connect --daemon` 首次启动")
}
@@ -788,10 +907,15 @@ func newDevAppRobotConnectRestartCommand() *cobra.Command {
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")
}
if commandDryRun(cmd) {
return writeConnectRestartPreview(cmd, dirKey, unifiedAppID, st.Channel)
}
// 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)
// 统一输出 dev 域试点(队列 B113):stopping/restarting 进度行改走
// stderr,stdout 只承载最终结果信封(契约规范 §5.1)。
fmt.Fprintln(cmd.ErrOrStderr(), "stopping existing daemon...")
if err := daemonStop(cmd.ErrOrStderr(), dirKey); err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "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.
@@ -816,24 +940,51 @@ func newDevAppRobotConnectRestartCommand() *cobra.Command {
if st.AlwaysOn {
args = append(args, "--alwayson")
}
fmt.Fprintf(cmd.OutOrStdout(), "restarting connector: dws %s\n", strings.Join(args, " "))
fmt.Fprintf(cmd.ErrOrStderr(), "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.
// instead of a silent success. 子进程自己的输出(含它自己的结果
// 信封)不再转发到本命令的 stdout——由本命令的信封统一承载结果,
// 避免两个 JSON 文档拼接破坏 `| jq`(契约规范 §5.1)。
restartCmd := daemonCommand(exe, args...)
restartCmd.Stdout = cmd.OutOrStdout()
restartCmd.Stderr = cmd.OutOrStderr()
restartCmd.Stdout = cmd.ErrOrStderr()
restartCmd.Stderr = cmd.ErrOrStderr()
restartCmd.Stdin = nil
if err := restartCmd.Run(); err != nil {
return apperrors.NewInternal(fmt.Sprintf("重启失败(旧守护进程已停止,连接器记录已清除);恢复请手动执行: dws %s", strings.Join(args, " ")))
}
return nil
// 回读刷新后的 daemon-state.json 拿新 supervisor pid(restart 是同步
// re-exec,子进程落盘早于返回;读不到则 pid 缺席,status 仍诚实)。
result := &connectRestartResult{
Status: "restarted",
DirKey: dirKey,
UnifiedAppID: unifiedAppID,
Channel: st.Channel,
Command: "dws " + strings.Join(args, " "),
}
if fresh, rerr := readDaemonState(dir); rerr == nil && fresh != nil {
result.Pid = fresh.Pid
}
env := &output.Envelope{OK: true, Outcome: output.OutcomeSuccess, Data: result}
return writeDevRolloutResult(cmd, output.Success(result), env, output.FormatJSON)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("robot-client-id", "", "机器人 clientId(定位守护进程)")
cmd.Flags().String("unified-app-id", "", "统一应用 ID(当未用 clientId 起守护进程时定位)")
DeclareLeafMetadata(cmd, LeafSpec{
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "not_required", Idempotency: "unknown"},
Validate: func(c *cobra.Command, _ []string) error { return validateConnectRestart(c) },
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{ProductID: "dev", Name: "connect_restart", CanonicalPath: "dev.connect_restart", CLIPath: "dev connect restart", PrimaryCLIPath: "dev connect restart"},
Description: "使用持久化配置重启本地连接器守护进程",
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewPlan, RemoteReads: false},
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "命令组合本地守护进程管理与远端凭证重新获取,不对应单一 MCP 接口"},
Selection: contract.SelectionSpec{AgentSummary: "重启本地连接器守护进程", UseWhen: []string{"连接器需要恢复或重载配置"}, AvoidWhen: []string{"仅查看状态时使用 dev connect status"}, Examples: []string{"dws dev connect restart --unified-app-id <unifiedAppId>", "dws dev connect restart --unified-app-id <unifiedAppId> --dry-run --format json"}},
},
})
return cmd
}
@@ -855,6 +1006,9 @@ func newDevAppRobotConnectListCommand(runner executor.Runner) *cobra.Command {
resolveAppNames(cmd, runner, reports)
w := cmd.OutOrStdout()
if jsonOut, _ := cmd.Flags().GetBool("json"); jsonOut {
if reports == nil {
reports = []connectHealthReport{}
}
data, _ := json.MarshalIndent(reports, "", " ")
fmt.Fprintln(w, string(data))
return nil
@@ -868,6 +1022,19 @@ func newDevAppRobotConnectListCommand(runner executor.Runner) *cobra.Command {
}
preferLegacyLeaf(cmd)
cmd.Flags().Bool("json", false, "以 JSON 数组输出(供脚本消费)")
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{ProductID: "dev", Name: "connect_list", CanonicalPath: "dev.connect_list", CLIPath: "dev connect list", PrimaryCLIPath: "dev connect list"},
Description: "列出本机连接器及其健康状态",
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"array","description":"本机连接器及其健康状态列表","items":{"type":"object","properties":{"state":{"type":"string","description":"归一化健康状态","enum":["healthy","degraded","down","not_running"]},"pid":{"type":"integer","description":"守护进程 PID;未运行时可能缺省"},"channel":{"type":"string","description":"连接器使用的通道"},"clientId":{"type":"string","description":"连接器客户端 ID"},"unifiedAppId":{"type":"string","description":"关联的统一应用 ID"},"supervised":{"type":"boolean","description":"是否由本地 supervisor 管理"}},"required":["state","supervised"],"additionalProperties":true}}`),
},
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "命令组合本地连接器状态与可选远端应用名称解析,不对应单一 MCP 接口"},
Selection: contract.SelectionSpec{AgentSummary: "列出本机全部连接器及健康状态", UseWhen: []string{"需要查看本机连接器清单"}, AvoidWhen: []string{"只检查一个连接器时使用 dev connect status"}, Examples: []string{"dws dev connect list --json"}},
},
})
return cmd
}
@@ -14,6 +14,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/spf13/cobra"
)
@@ -164,13 +165,18 @@ func TestCrossPlatformCoverageStartDaemonLifecycleEdges(t *testing.T) {
fixture := writeShellExecutable(t, t.TempDir(), "daemon-success", "exit 0\n")
daemonCommand = func(string, ...string) *exec.Cmd { return exec.Command(fixture) }
cmd := daemonTestCommand()
var out bytes.Buffer
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
if err := startDaemon(cmd, "key", "client", "app", "custom", "staff", "profile", true); err != nil {
t.Fatalf("startDaemon() error = %v", err)
}
if !strings.Contains(out.String(), "daemon started") {
t.Fatalf("start output = %q", out.String())
// The streaming root remains legacy until a dedicated stream contract.
if !strings.Contains(out.String(), "daemon started") || !strings.Contains(out.String(), "pid") {
t.Fatalf("legacy daemon start output = %q", out.String())
}
if errOut.Len() != 0 {
t.Fatalf("legacy daemon start stderr = %q", errOut.String())
}
// startDaemon intentionally releases its detached child. On Windows the
@@ -564,7 +570,7 @@ func TestCrossPlatformCoverageDaemonStatusAndStopEdges(t *testing.T) {
t.Fatal(err)
}
connectDaemonDirOverride = blocked
if err := daemonStop(&bytes.Buffer{}, "key"); err == nil {
if _, err := daemonStopResult(&bytes.Buffer{}, "key"); err == nil {
t.Fatal("stop with blocked directory succeeded")
}
connectDaemonDirOverride = t.TempDir()
@@ -572,7 +578,7 @@ func TestCrossPlatformCoverageDaemonStatusAndStopEdges(t *testing.T) {
if err := os.WriteFile(daemonStatePath(dir), []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
if err := daemonStop(&bytes.Buffer{}, "corrupt"); err == nil {
if _, err := daemonStopResult(&bytes.Buffer{}, "corrupt"); err == nil {
t.Fatal("stop with corrupt state succeeded")
}
})
@@ -586,7 +592,7 @@ func TestCrossPlatformCoverageDaemonStatusAndStopEdges(t *testing.T) {
}
daemonProcessAlive = func(int) bool { return true }
daemonFindProcess = func(int) (*os.Process, error) { return nil, errors.New("find") }
if err := daemonStop(&bytes.Buffer{}, "find-error"); err == nil {
if _, err := daemonStopResult(&bytes.Buffer{}, "find-error"); err == nil {
t.Fatal("find process error was ignored")
}
})
@@ -634,7 +640,7 @@ func TestCrossPlatformCoverageDaemonStopHookedLifecycleEdges(t *testing.T) {
}
sleeps := 0
helperSleep = func(time.Duration) { sleeps++ }
if err := daemonStop(&bytes.Buffer{}, "orphan-graceful"); err != nil {
if _, err := daemonStopResult(&bytes.Buffer{}, "orphan-graceful"); err != nil {
t.Fatal(err)
}
if len(signals) != 1 || signals[0] != syscall.SIGTERM || sleeps != 1 {
@@ -660,7 +666,7 @@ func TestCrossPlatformCoverageDaemonStopHookedLifecycleEdges(t *testing.T) {
return nil
}
helperSleep = func(time.Duration) {}
if err := daemonStop(&bytes.Buffer{}, "orphan-force-hooked"); err != nil {
if _, err := daemonStopResult(&bytes.Buffer{}, "orphan-force-hooked"); err != nil {
t.Fatal(err)
}
if len(signals) != 2 || signals[0] != syscall.SIGTERM || signals[1] != syscall.SIGKILL {
@@ -678,7 +684,7 @@ func TestCrossPlatformCoverageDaemonStopHookedLifecycleEdges(t *testing.T) {
base := time.Now()
daemonNow = func() time.Time { return base }
daemonSignalProcess = func(*os.Process, os.Signal) error { return nil }
if err := daemonStop(&bytes.Buffer{}, "live-graceful-hooked"); err != nil {
if _, err := daemonStopResult(&bytes.Buffer{}, "live-graceful-hooked"); err != nil {
t.Fatal(err)
}
})
@@ -687,7 +693,7 @@ func TestCrossPlatformCoverageDaemonStopHookedLifecycleEdges(t *testing.T) {
prepare(t, "live-signal-error-hooked", false)
daemonProcessAlive = func(int) bool { return true }
daemonSignalProcess = func(*os.Process, os.Signal) error { return errors.New("signal") }
if err := daemonStop(&bytes.Buffer{}, "live-signal-error-hooked"); err == nil {
if _, err := daemonStopResult(&bytes.Buffer{}, "live-signal-error-hooked"); err == nil {
t.Fatal("signal error was ignored")
}
})
@@ -710,7 +716,7 @@ func TestCrossPlatformCoverageDaemonStopHookedLifecycleEdges(t *testing.T) {
return nil
}
helperSleep = func(time.Duration) {}
if err := daemonStop(&bytes.Buffer{}, "live-force-hooked"); err != nil {
if _, err := daemonStopResult(&bytes.Buffer{}, "live-force-hooked"); err != nil {
t.Fatal(err)
}
if len(signals) != 2 || signals[0] != syscall.SIGTERM || signals[1] != syscall.SIGKILL {
@@ -763,25 +769,25 @@ func TestCrossPlatformCoverageDaemonListAndNamePaginationEdges(t *testing.T) {
}
resolveAppNames(cmd, connectResponseRunner{err: errors.New("offline")}, []connectHealthReport{{UnifiedAppID: "u-1"}})
list := newDevAppRobotConnectListCommand(runner)
list := prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(runner))
var out bytes.Buffer
list.SetOut(&out)
if err := list.Execute(); err != nil || !strings.Contains(out.String(), "no connectors") {
t.Fatalf("empty list = %q, %v", out.String(), err)
if err := list.Execute(); err != nil || strings.TrimSpace(out.String()) != "no connectors found" {
t.Fatalf("empty list output = %q, %v", out.String(), err)
}
list = newDevAppRobotConnectListCommand(runner)
list = prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(runner))
out.Reset()
list.SetOut(&out)
list.SetArgs([]string{"--json"})
if err := list.Execute(); err != nil || !strings.Contains(out.String(), "null") {
t.Fatalf("json list = %q, %v", out.String(), err)
if err := list.Execute(); err != nil || strings.TrimSpace(out.String()) != "[]" {
t.Fatalf("json list array = %q, %v", out.String(), err)
}
seedHeartbeat(t, "listed", connectHeartbeat{
Pid: os.Getpid(), ClientID: strings.Repeat("c", 80), Channel: strings.Repeat("x", 80),
StartUnix: time.Now().Add(-time.Minute).Unix(), ConnectedUnix: time.Now().Add(-time.Minute).Unix(),
})
list = newDevAppRobotConnectListCommand(runner)
list = prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(runner))
out.Reset()
list.SetOut(&out)
if err := list.Execute(); err != nil || !strings.Contains(out.String(), "STATE") {
@@ -791,7 +797,7 @@ func TestCrossPlatformCoverageDaemonListAndNamePaginationEdges(t *testing.T) {
connectHealthReadDir = func(string) ([]os.DirEntry, error) {
return nil, errors.New("read directory")
}
list = newDevAppRobotConnectListCommand(runner)
list = prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(runner))
list.SetOut(&bytes.Buffer{})
if err := list.Execute(); err == nil {
t.Fatal("list with blocked directory succeeded")
@@ -806,6 +812,7 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
defaultFindProcess := daemonFindProcess
for _, command := range []*cobra.Command{newDevAppRobotConnectStatusCommand(), newDevAppRobotConnectStopCommand(), newDevAppRobotConnectRestartCommand()} {
command = prepareUnifiedTestCommand(command)
command.SetArgs(nil)
command.SetOut(&bytes.Buffer{})
command.SetErr(&bytes.Buffer{})
@@ -814,20 +821,20 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
}
}
status := newDevAppRobotConnectStatusCommand()
status := prepareUnifiedTestCommand(newDevAppRobotConnectStatusCommand())
status.SetArgs([]string{"--robot-client-id", "missing", "--json"})
status.SetOut(&bytes.Buffer{})
if err := status.Execute(); err != nil {
t.Fatalf("status command = %v", err)
}
stop := newDevAppRobotConnectStopCommand()
stop.SetArgs([]string{"--unified-app-id", "missing"})
stop := prepareUnifiedTestCommand(newDevAppRobotConnectStopCommand())
stop.SetArgs([]string{"--unified-app-id", "missing", "--yes"})
stop.SetOut(&bytes.Buffer{})
if err := stop.Execute(); err != nil {
t.Fatalf("stop command = %v", err)
}
restart := newDevAppRobotConnectRestartCommand()
restart := prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
restart.SetArgs([]string{"--robot-client-id", "missing"})
restart.SetOut(&bytes.Buffer{})
restart.SetErr(&bytes.Buffer{})
@@ -840,7 +847,7 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
t.Fatal(err)
}
connectDaemonDirOverride = blocked
restart = newDevAppRobotConnectRestartCommand()
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
restart.SetArgs([]string{"--robot-client-id", "blocked"})
restart.SetOut(&bytes.Buffer{})
restart.SetErr(&bytes.Buffer{})
@@ -852,7 +859,7 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
if err := os.WriteFile(daemonStatePath(corruptDir), []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
restart = newDevAppRobotConnectRestartCommand()
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
restart.SetArgs([]string{"--robot-client-id", "corrupt-restart"})
restart.SetOut(&bytes.Buffer{})
restart.SetErr(&bytes.Buffer{})
@@ -879,8 +886,8 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
}
daemonExecutable = func() (string, error) { return "/bin/sh", nil }
daemonCommand = func(string, ...string) *exec.Cmd { return exec.Command("sh", "-c", "exit 0") }
restart = newDevAppRobotConnectRestartCommand()
restart.SetArgs([]string{"--robot-client-id", "restart"})
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
restart.SetArgs([]string{"--robot-client-id", "restart", "--yes"})
restart.SetOut(&bytes.Buffer{})
restart.SetErr(&bytes.Buffer{})
if err := restart.Execute(); err != nil {
@@ -892,8 +899,8 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
}
daemonProcessAlive = func(int) bool { return true }
daemonFindProcess = func(int) (*os.Process, error) { return nil, errors.New("find") }
restart = newDevAppRobotConnectRestartCommand()
restart.SetArgs([]string{"--robot-client-id", "restart"})
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
restart.SetArgs([]string{"--robot-client-id", "restart", "--yes"})
restart.SetOut(&bytes.Buffer{})
restart.SetErr(&bytes.Buffer{})
if err := restart.Execute(); err != nil {
@@ -906,10 +913,19 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
t.Fatal(err)
}
root := &cobra.Command{Use: "dws"}
ctx, _ := output.WithResultStore(context.Background())
root.SetContext(ctx)
root.PersistentFlags().String("profile", "", "")
root.PersistentFlags().String("format", "json", "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().Bool("yes", false, "")
root.PersistentPostRunE = func(cmd *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(cmd)
return err
}
restart = newDevAppRobotConnectRestartCommand()
root.AddCommand(restart)
root.SetArgs([]string{"restart", "--robot-client-id", "restart", "--profile", "override"})
root.SetArgs([]string{"restart", "--robot-client-id", "restart", "--profile", "override", "--yes"})
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
if err := root.Execute(); err != nil {
@@ -941,3 +957,50 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
t.Fatal("failing restart subprocess succeeded")
}
}
func TestFrameworkConnectControlDryRunPlansAndLegacyListRollback(t *testing.T) {
preserveDaemonHooks(t)
connectDaemonDirOverride = t.TempDir()
stop := prepareUnifiedTestCommand(newDevAppRobotConnectStopCommand())
stop.SetArgs([]string{"--unified-app-id", "app", "--dry-run"})
var stopOut bytes.Buffer
stop.SetOut(&stopOut)
if err := stop.Execute(); err != nil || !strings.Contains(stopOut.String(), `"preview_kind": "plan"`) {
t.Fatalf("stop preview=%q err=%v", stopOut.String(), err)
}
restart := prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
restart.SetArgs([]string{"--unified-app-id", "app", "--dry-run"})
var restartOut bytes.Buffer
restart.SetOut(&restartOut)
if err := restart.Execute(); err != nil || !strings.Contains(restartOut.String(), `"preview_kind": "plan"`) {
t.Fatalf("restart preview=%q err=%v", restartOut.String(), err)
}
dir, err := connectDaemonDir("saved")
if err != nil {
t.Fatal(err)
}
if err := writeDaemonState(dir, daemonState{DirKey: "saved", UnifiedAppID: "saved-app", Channel: "custom"}); err != nil {
t.Fatal(err)
}
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
restart.SetArgs([]string{"--robot-client-id", "saved", "--dry-run"})
restartOut.Reset()
restart.SetOut(&restartOut)
if err := restart.Execute(); err != nil || !strings.Contains(restartOut.String(), "saved-app") {
t.Fatalf("saved restart preview=%q err=%v", restartOut.String(), err)
}
list := prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(connectResponseRunner{response: map[string]any{"items": []any{}, "hasMore": false}}))
output.SetCommandRollout(list, output.RolloutLegacyOnly)
for _, format := range []string{"json", "table", "pretty"} {
var out bytes.Buffer
list.SetOut(&out)
list.SetArgs([]string{"--format", format})
if err := list.Execute(); err != nil {
t.Fatalf("legacy list %s: %v", format, err)
}
}
}
@@ -0,0 +1,135 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/spf13/cobra"
)
func TestConnectStopAndRestartDoNotRequireConfirmation(t *testing.T) {
for _, cmd := range []*cobra.Command{
newDevAppRobotConnectStopCommand(),
newDevAppRobotConnectRestartCommand(),
} {
final, ok := contractfinal.RuntimeContractFinal(cmd)
if !ok || final.Safety == nil {
t.Fatalf("%s missing final safety declaration", cmd.Name())
}
if got := final.Safety.Confirmation; got != "not_required" {
t.Fatalf("%s confirmation = %q, want not_required", cmd.Name(), got)
}
}
}
// TestConnectDaemonFamilyMissingDaemonErrorPaths 是队列 B116 的「daemon 不存在
// 错误路径」分支:对不存在的守护进程,status/stop 不得把它当成失败(空态是合法
// 载荷,AC-06——status 保持已发布的顶层 JSON 并如实标注 not_running),restart 因无法安全
// 重建而报 validation 错误,缺定位标识则统一报 validation(错误路径继续走
// apperrors 通道,不进信封)。命令级端到端,stdout/stderr 分流断言。
func TestConnectDaemonFamilyMissingDaemonErrorPaths(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
// status:daemon 不存在 → 兼容顶层 JSON,state=not_running(非错误)。
status := prepareUnifiedTestCommand(newDevAppRobotConnectStatusCommand())
var statusOut, statusErr bytes.Buffer
status.SetOut(&statusOut)
status.SetErr(&statusErr)
status.SetArgs([]string{"--robot-client-id", "ghost", "--json"})
if err := status.Execute(); err != nil {
t.Fatalf("status on missing daemon must not error, got %v\nstderr:\n%s", err, statusErr.String())
}
var statusReport connectHealthReport
if err := json.Unmarshal(statusOut.Bytes(), &statusReport); err != nil {
t.Fatalf("status output is not the published top-level JSON: %v\n%s", err, statusOut.String())
}
if statusReport.State != healthNotRunning {
t.Fatalf("status state = %q, want %q: %s", statusReport.State, healthNotRunning, statusOut.String())
}
if strings.Contains(statusOut.String(), `"outcome"`) || strings.Contains(statusOut.String(), `"data"`) {
t.Fatalf("status compatibility JSON was enveloped: %s", statusOut.String())
}
// stop:daemon 不存在 → ok:true 信封,data.status=not_running;人读行走 stderr。
stop := prepareUnifiedTestCommand(newDevAppRobotConnectStopCommand())
var stopOut, stopErr bytes.Buffer
stop.SetOut(&stopOut)
stop.SetErr(&stopErr)
stop.SetArgs([]string{"--unified-app-id", "ghost"})
if err := stop.Execute(); err != nil {
t.Fatalf("stop on missing daemon must not error, got %v\nstderr:\n%s", err, stopErr.String())
}
stopEnv := decodePhaseFEnvelope(t, stopOut.Bytes())
if !stopEnv.OK || stopEnv.Outcome != "success" {
t.Fatalf("stop envelope ok/outcome = %v/%q, want true/success: %s",
stopEnv.OK, stopEnv.Outcome, stopOut.String())
}
if st, _ := stopEnv.Data["status"].(string); st != "not_running" {
t.Fatalf("stop data.status = %q, want not_running: %s", st, stopOut.String())
}
if !strings.Contains(stopErr.String(), "not running") {
t.Fatalf("stop human-readable line missing from stderr: %q", stopErr.String())
}
// restart:daemon 不存在 → validation 错误(无法安全重建),非信封。
// SilenceUsage 对齐生产根命令(internal/app/root.go:SilenceUsage=true),
// 否则单叶子 Execute() 报错时 Cobra 默认把 usage 打 stdout,污染断言。
restart := prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
restart.SilenceUsage = true
var restartOut, restartErr bytes.Buffer
restart.SetOut(&restartOut)
restart.SetErr(&restartErr)
restart.SetArgs([]string{"--robot-client-id", "ghost"})
err := restart.Execute()
if err == nil || !strings.Contains(err.Error(), "未找到连接器记录") {
t.Fatalf("restart on missing daemon error = %v, want 未找到连接器记录", err)
}
if restartOut.Len() != 0 {
t.Fatalf("restart error path must keep stdout empty, got %q", restartOut.String())
}
}
// TestConnectDaemonFamilyRequiresLocatorIdentity 是队列 B116 的配套断言:
// status/stop/restart 无定位标识(--robot-client-id / --unified-app-id 均缺)
// 时统一报 validation 错误,错误路径不进信封、stdout 零字节。
func TestConnectDaemonFamilyRequiresLocatorIdentity(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
for _, build := range []func() *cobra.Command{
func() *cobra.Command { return newDevAppRobotConnectStatusCommand() },
func() *cobra.Command { return newDevAppRobotConnectStopCommand() },
func() *cobra.Command { return newDevAppRobotConnectRestartCommand() },
} {
cmd := prepareUnifiedTestCommand(build())
cmd.SilenceUsage = true
var out, errBuf bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errBuf)
cmd.SetArgs(nil)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "需要 --robot-client-id 或 --unified-app-id") {
t.Fatalf("%s without locator error = %v, want 定位守护进程 validation", cmd.Name(), err)
}
if out.Len() != 0 {
t.Fatalf("%s validation error must keep stdout empty, got %q", cmd.Name(), out.String())
}
}
}
+6 -2
View File
@@ -269,6 +269,10 @@ func TestDaemonStatusJSON(t *testing.T) {
if err := json.Unmarshal(buf.Bytes(), &report); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
}
// --json is a published supervisor API: health fields remain at top level.
if strings.Contains(buf.String(), `"outcome"`) || strings.Contains(buf.String(), `"data"`) {
t.Fatalf("status compatibility JSON was enveloped: %s", buf.String())
}
if report.State != healthHealthy {
t.Errorf("state = %q, want %q", report.State, healthHealthy)
}
@@ -281,7 +285,7 @@ func TestDaemonStopNotRunning(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
var buf bytes.Buffer
if err := daemonStop(&buf, "ghost"); err != nil {
if _, err := daemonStopResult(&buf, "ghost"); err != nil {
t.Fatalf("daemonStop: %v", err)
}
if !strings.Contains(buf.String(), "not running") {
@@ -295,7 +299,7 @@ func TestDaemonStopStaleCleansPidFile(t *testing.T) {
dir, _ := connectDaemonDir("stalestop")
writeDaemonState(dir, daemonState{Pid: deadPid(t), StartUnix: time.Now().Unix(), DirKey: "stalestop"})
var buf bytes.Buffer
if err := daemonStop(&buf, "stalestop"); err != nil {
if _, err := daemonStopResult(&buf, "stalestop"); err != nil {
t.Fatalf("daemonStop: %v", err)
}
if _, err := os.Stat(daemonPidPath(dir)); !os.IsNotExist(err) {
+1 -1
View File
@@ -84,7 +84,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command {
},
}
cmdutil.MarkGroup(doc)
doc.AddCommand(newDevDocSearchCommand())
doc.AddCommand(newDevDocSearchCommand(runner))
root.AddCommand(
newDevAppCommand(runner),
+160
View File
@@ -0,0 +1,160 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/spf13/cobra"
)
type countingDevUnifiedRunner struct {
calls int
}
func (r *countingDevUnifiedRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.calls++
invocation.Implemented = true
return executor.Result{
Invocation: invocation,
Response: map[string]any{"content": map[string]any{
"success": true,
"result": map[string]any{"id": "dev-1", "hasMore": false},
}},
}, nil
}
func newDevUnifiedRoot(runner executor.Runner) *cobra.Command {
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
ctx, _ := output.WithResultStore(context.Background())
root.SetContext(ctx)
root.PersistentFlags().String("format", "json", "")
root.PersistentFlags().String("fields", "", "")
root.PersistentFlags().String("jq", "", "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().Bool("yes", false, "")
root.PersistentPostRunE = func(cmd *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(cmd)
return err
}
root.AddCommand(devHandler{}.Command(runner))
return root
}
func TestDevAppUnifiedActiveExecutesOnceAndReturnsFrameworkResult(t *testing.T) {
runner := &countingDevUnifiedRunner{}
root := newDevUnifiedRoot(runner)
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetArgs([]string{"dev", "app", "list"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if runner.calls != 1 {
t.Fatalf("runner calls=%d, want exactly 1", runner.calls)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"outcome": "success"`)) || !bytes.Contains(stdout.Bytes(), []byte(`"id": "dev-1"`)) {
t.Fatalf("stdout=%s", stdout.String())
}
}
func TestMigratedDevAppDefaultsToUnifiedFramework(t *testing.T) {
runner := &countingDevUnifiedRunner{}
root := newDevUnifiedRoot(runner)
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"dev", "app", "list"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if runner.calls != 1 {
t.Fatalf("runner calls=%d, want 1", runner.calls)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"outcome": "success"`)) || bytes.Contains(stdout.Bytes(), []byte(`"contract_version"`)) {
t.Fatalf("migrated dev command did not use unified output by default: %s", stdout.String())
}
}
func TestDevDocSearchStaysLegacyUntilPagePaginationContractExists(t *testing.T) {
cmd := newDevDocSearchCommand(&countingDevUnifiedRunner{})
if got := output.CommandRollout(cmd); got != output.RolloutLegacyOnly {
t.Fatalf("dev doc search rollout=%s, want legacy_only until page pagination is modeled", got)
}
final, ok := contractfinal.RuntimeContractFinal(cmd)
if !ok {
t.Fatal("dev doc search is missing ContractFinal")
}
if final.Result != nil || final.Pagination != nil {
t.Fatalf("legacy dev doc search must not publish unified result/pagination schema: %#v", final)
}
}
func TestDevTerminalRolloutKeepsPublishedConnectStatusLegacy(t *testing.T) {
root := newDevUnifiedRoot(&countingDevUnifiedRunner{})
dev, _, err := root.Find([]string{"dev"})
if err != nil {
t.Fatal(err)
}
var walk func(*cobra.Command)
walk = func(cmd *cobra.Command) {
children := cmd.Commands()
if cmd.Runnable() && len(children) == 0 {
want := output.RolloutUnifiedActive
switch cmd.CommandPath() {
case "dws dev connect status", "dws dev connect list", "dws dev doc search":
want = output.RolloutLegacyOnly
}
if got := output.CommandRollout(cmd); got != want {
t.Errorf("%s rollout=%s, want %s", cmd.CommandPath(), got, want)
}
}
if cmd.CommandPath() == "dws dev connect" && output.CommandRollout(cmd) != output.RolloutLegacyOnly {
t.Errorf("%s must remain legacy until a streaming contract exists", cmd.CommandPath())
}
for _, child := range children {
walk(child)
}
}
walk(dev)
}
func TestDevConnectStatusPreservesPublishedHumanAndJSONShapes(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
humanRoot := newDevUnifiedRoot(&countingDevUnifiedRunner{})
var humanOut bytes.Buffer
humanRoot.SetOut(&humanOut)
humanRoot.SetArgs([]string{"dev", "connect", "status", "--robot-client-id", "missing"})
if err := humanRoot.Execute(); err != nil {
t.Fatal(err)
}
if !bytes.Contains(humanOut.Bytes(), []byte("not_running")) || bytes.Contains(humanOut.Bytes(), []byte(`"outcome"`)) {
t.Fatalf("default status output no longer uses the legacy human view: %s", humanOut.String())
}
jsonRoot := newDevUnifiedRoot(&countingDevUnifiedRunner{})
var jsonOut bytes.Buffer
jsonRoot.SetOut(&jsonOut)
jsonRoot.SetArgs([]string{"dev", "connect", "status", "--robot-client-id", "missing", "--json"})
if err := jsonRoot.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(jsonOut.Bytes(), &payload); err != nil {
t.Fatalf("legacy --json output is invalid: %v\n%s", err, jsonOut.String())
}
if payload["state"] != "not_running" || payload["supervised"] != false {
t.Fatalf("legacy health fields moved or changed: %#v", payload)
}
for _, forbidden := range []string{"ok", "outcome", "data"} {
if _, exists := payload[forbidden]; exists {
t.Fatalf("legacy --json unexpectedly gained unified key %q: %#v", forbidden, payload)
}
}
}
+490 -11
View File
@@ -22,6 +22,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
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/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
@@ -79,6 +80,36 @@ const (
devAppVersionStatusTool = "get_dev_app_version_status"
)
func devAppPaginatedItemsResult(description, itemDescription string) *contract.ResultSpec {
schema, _ := json.Marshal(map[string]any{
"type": "object",
"description": description,
"additionalProperties": true,
"properties": map[string]any{
"items": map[string]any{
"type": "array",
"description": itemDescription + ";分页控制信息只读取 meta.pagination",
"items": map[string]any{
"type": "object",
"additionalProperties": true,
},
},
},
"required": []string{"items"},
})
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: schema,
}
}
func devAppCursorPagination() *contract.PaginationSpec {
return &contract.PaginationSpec{
Kind: contract.PaginationKindCursor,
CursorParameter: "cursor",
}
}
// newDevAppCommand builds the `app` subtree of `dws dev`. The cobra path is
// `dws dev app ...` while the MCP product id stays "devapp" — the id is a
// backend contract (SupplementServers/StaticServers injection key and the
@@ -289,6 +320,8 @@ func newDevAppEventListCommand(runner executor.Runner) *cobra.Command {
},
Description: "查询应用已订阅的事件列表",
DryRun: devAppDryRun,
Result: devAppPaginatedItemsResult("当前页应用订阅事件查询结果", "当前页事件记录"),
Pagination: devAppCursorPagination(),
Interface: devAppCompositeInterface(),
Selection: contract.SelectionSpec{
AgentSummary: "列出或搜索应用可订阅的事件",
@@ -402,7 +435,12 @@ func newDevAppListCommand(runner executor.Runner) *cobra.Command {
},
Description: "查询开放平台企业内部应用列表",
DryRun: devAppDryRun,
Interface: devAppCompositeInterface(),
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeFailure, contract.ResultOutcomeSuccess},
DataSchema: json.RawMessage(`{"type":"object","description":"当前页应用查询结果","properties":{"items":{"type":"array","description":"当前页应用记录","items":{"type":"object","properties":{"unifiedAppId":{"type":"string","description":"开放平台统一应用 ID"},"name":{"type":"string","description":"应用名称"},"appKey":{"type":"string","description":"应用 AppKey"}},"additionalProperties":true}}},"required":["items"],"additionalProperties":true}`),
},
Pagination: devAppCursorPagination(),
Interface: devAppCompositeInterface(),
Selection: contract.SelectionSpec{
AgentSummary: "按条件分页查询开放平台应用",
UseWhen: []string{"需要按名称、创建人或应用键筛选应用时"},
@@ -445,7 +483,11 @@ func newDevAppGetCommand(runner executor.Runner) *cobra.Command {
},
Description: "查询开放平台企业内部应用详情",
DryRun: devAppDryRun,
Interface: devAppCompositeInterface(),
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"开放平台企业内部应用详情","properties":{"unifiedAppId":{"type":"string","description":"开放平台统一应用 ID"},"name":{"type":"string","description":"应用名称"},"appKey":{"type":"string","description":"应用 AppKey"},"agentId":{"description":"应用 Agent ID;具体类型由服务端返回"},"status":{"description":"应用当前状态;具体类型由服务端返回"}},"required":["unifiedAppId"],"additionalProperties":true}`),
},
Interface: devAppCompositeInterface(),
Selection: contract.SelectionSpec{
AgentSummary: "获取指定开放平台应用详情",
UseWhen: []string{"已知 unifiedAppId 或 appKey 并需要核对应用配置或状态时"},
@@ -565,7 +607,12 @@ func newDevAppCredentialsGetCommand(runner executor.Runner) *cobra.Command {
},
Description: "读取开放平台应用凭证",
DryRun: devAppDryRun,
Interface: devAppCompositeInterface(),
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"开放平台应用凭证;敏感字段必须经过统一脱敏策略","properties":{"clientId":{"type":"string","description":"OAuth 客户端 ID"},"clientSecret":{"type":"string","description":"OAuth 客户端密钥"},"appKey":{"type":"string","description":"应用 AppKey"},"appSecret":{"type":"string","description":"应用 AppSecret"}},"additionalProperties":true}`),
SensitivePaths: []string{"appSecret", "clientSecret"},
},
Interface: devAppCompositeInterface(),
Selection: contract.SelectionSpec{
AgentSummary: "读取指定应用的客户端凭证",
UseWhen: []string{"已知 unifiedAppId 且需要 clientId 或 clientSecret 时"},
@@ -863,9 +910,12 @@ func newDevAppPermissionListCommand(runner executor.Runner) *cobra.Command {
CanonicalPath: "dev.list_dev_app_permissions",
CLIPath: "dev app permission list",
PrimaryCLIPath: "dev app permission list",
Aliases: []string{"dev app permission search"},
},
Description: "查询开放平台应用权限列表",
DryRun: devAppDryRun,
Result: devAppPaginatedItemsResult("当前页开放平台应用权限查询结果", "当前页权限记录"),
Pagination: devAppCursorPagination(),
Interface: devAppCompositeInterface(),
Selection: contract.SelectionSpec{
AgentSummary: "查询应用权限及其授权状态",
@@ -1537,6 +1587,8 @@ func newDevAppVersionListCommand(runner executor.Runner) *cobra.Command {
},
Description: "分页查询应用版本列表",
DryRun: devAppDryRun,
Result: devAppPaginatedItemsResult("当前页开放平台应用版本查询结果", "当前页版本记录"),
Pagination: devAppCursorPagination(),
Interface: devAppCompositeInterface(),
Selection: contract.SelectionSpec{
AgentSummary: "分页列出应用的历史和待发布版本",
@@ -1596,8 +1648,23 @@ func newDevAppVersionCheckApprovalCommand(runner executor.Runner) *cobra.Command
{Name: "version-id", Usage: "版本 ID (必填)", Bind: "versionId", Trim: true, Required: true, RequiredHint: "--version-id 为必填"},
},
ConstParams: map[string]any{"precheckOnly": true},
Call: devAppCall(runner),
PostMount: devAppMeta(devAppVersionPublishTool),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "dev", Name: "version_check_approval", CanonicalPath: "dev.version_check_approval",
CLIPath: "dev app version check-approval", PrimaryCLIPath: "dev app version check-approval",
},
Description: "预检版本发布是否需要审批,不执行发布",
DryRun: devAppDryRun,
Interface: devAppCompositeInterface(),
Selection: contract.SelectionSpec{
AgentSummary: "预检应用版本的审批要求和候选审批人",
UseWhen: []string{"发布版本前确认是否需要审批及候选审批人"},
AvoidWhen: []string{"实际发布版本时使用 dev app version publish"},
Examples: []string{"dws dev app version check-approval --unified-app-id <unifiedAppId> --version-id <versionId>"},
},
},
Call: devAppCall(runner),
PostMount: devAppMeta(devAppVersionPublishTool),
})
}
@@ -1661,7 +1728,11 @@ func newDevAppVersionStatusCommand(runner executor.Runner) *cobra.Command {
},
Description: "查询版本发布/审批状态",
DryRun: devAppDryRun,
Interface: devAppCompositeInterface(),
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomePending, contract.ResultOutcomeFailure, contract.ResultOutcomeSuccess},
DataSchema: json.RawMessage(`{"type":"object","description":"应用版本发布或审批状态","properties":{"unifiedAppId":{"type":"string","description":"开放平台统一应用 ID"},"versionId":{"type":"string","description":"应用版本 ID"},"status":{"type":"string","description":"归一化版本状态"},"versionStatus":{"type":"string","description":"服务端版本状态"},"approvalStatus":{"type":"string","description":"版本审批状态"},"nextCommand":{"type":"string","description":"状态未终结时可执行的下一条命令"}},"required":["versionId"],"additionalProperties":true}`),
},
Interface: devAppCompositeInterface(),
Selection: contract.SelectionSpec{
AgentSummary: "查询指定应用版本的发布或审批状态",
UseWhen: []string{"需要判断版本是否已发布、审核中或受阻时"},
@@ -1703,6 +1774,9 @@ func devAppLeafMeta(cmd *cobra.Command, tool string) {
cmd.DisableAutoGenTag = true
preferLegacyLeaf(cmd)
annotateDevAppTool(cmd, tool)
// This leaf has migrated to the unified result framework. Consumers keep using --format;
// the active contract is a release property, not an Agent-selected flag.
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
}
// devAppCall 返回统一派发闭包(替代各命令重复的 Call: runDevAppTool 透传)。
@@ -1776,6 +1850,9 @@ func runDevAppTool(runner executor.Runner, cmd *cobra.Command, tool string, para
if err != nil {
return err
}
// The requested tool is the local contract authority. Do not let an empty or
// stale runner echo disable tool-specific fail-closed projection rules.
result.Invocation.Tool = tool
// Unwrap the ServiceResult envelope and apply per-tool response fixes before
// rendering, so agents read the inner payload directly and pretty-annotation
// walks the already-normalized content.
@@ -1783,7 +1860,409 @@ func runDevAppTool(runner executor.Runner, cmd *cobra.Command, tool string, para
if devAppPrettyWanted(cmd) {
devAppPrettyAnnotate(tool, result.Response)
}
return writeCommandPayload(cmd, result)
return writeDevAppEnvelope(cmd, result)
}
func devAppCommandResult(result executor.Result) output.CommandResult {
data := devAppEnvelopeData(result)
if content, ok := data.(map[string]any); ok {
if partial := devAppMultiProfileResult(content); partial != nil {
return partial
}
if failure := devAppFailureResult(content); failure != nil {
return failure
}
// check-approval reports what a later publish would require; it does not
// itself accept an asynchronous operation.
precheckOnly, _ := result.Invocation.Params["precheckOnly"].(bool)
if !precheckOnly {
if pending := devAppPendingResult(content); pending != nil {
return pending
}
}
}
successOptions := make([]output.ResultOption, 0, 2)
if result.Invocation.DryRun {
successOptions = append(successOptions, output.WithDryRun())
}
if meta, err := devAppPaginationMeta(data); err != nil {
return output.Failure(&output.ErrorInfo{
Type: "api", Subtype: "pagination_inconsistent", Message: err.Error(),
Hint: "保留原始响应并停止翻页;不要把当前页当作完整结果。",
})
} else if meta != nil {
successOptions = append(successOptions, output.WithMeta(meta))
return output.Success(devAppDataWithoutPagination(data), successOptions...)
} else if devAppToolRequiresPagination(result.Invocation.Tool) && !result.Invocation.DryRun {
// A dry-run payload is a completed local invocation preview, not a
// server list response. Only real responses must prove pagination.
return output.Failure(&output.ErrorInfo{
Type: "api",
Subtype: "pagination_inconsistent",
Message: "declared paginated response is missing hasMore and nextCursor",
Hint: "保留原始响应并停止翻页;不要把当前页当作完整结果。",
})
}
return output.Success(data, successOptions...)
}
func devAppToolRequiresPagination(tool string) bool {
switch strings.TrimSpace(tool) {
case devAppListTool, devAppPermissionListTool, devAppEventListTool, devAppVersionListTool:
return true
default:
return false
}
}
// DevAppCommandResultFromPayload is the shared dingtalk-dev outcome mapper for
// the native `dev ...` tree and the existing `devapp +...` shortcut tree. Both
// entry points must classify the same upstream payload into the same unified
// outcome; only command routing and projection differ.
func DevAppCommandResultFromPayload(tool string, payload any, dryRun bool) output.CommandResult {
response := map[string]any{"content": payload}
if object, ok := payload.(map[string]any); ok {
if _, wrapped := object["content"]; wrapped {
response = object
}
}
result := executor.Result{
Invocation: executor.Invocation{
Implemented: true,
Kind: "helper_invocation",
DryRun: dryRun,
Tool: tool,
},
Response: response,
}
result = normalizeDevAppServiceResult(result)
if strings.TrimSpace(tool) != "" {
result = normalizeDevAppToolResult(tool, result)
}
return devAppCommandResult(result)
}
// writeDevRolloutResult is the gradual migration seam shared by dingtalk-dev.
// The operation is executed exactly once; only the renderer changes. Legacy
// remains active only for commands that have not advanced to unified_active.
func writeDevRolloutResult(cmd *cobra.Command, result output.CommandResult, legacy *output.Envelope, fallback output.Format) error {
if output.UsesUnifiedResult(cmd) {
return output.StoreResult(cmd.Context(), result)
}
if output.CommandRollout(cmd) == output.RolloutDualValidate {
// Shadow-build/validate the unified result without a second business invocation and
// without changing stdout. Metrics can be added around this seam later.
if err := output.ValidateResult(result); err != nil {
return err
}
}
return output.WriteEnvelope(cmd, legacy, fallback)
}
// writeDevAppEnvelope 是 dev app 全树的统一信封出口(统一输出 dev 域试点,
// 队列 Phase F)。成功 → ok:true/outcome:success + data;--dry-run →
// ok:true/outcome:success + dry_run:true:dry-run 是已完成的预演,不是异步未终态。
// exit 0,参数非法仍走 validation 报错——错误路径继续由 apperrors 通道承载)。
// json(默认)输出完整信封(唯一 JSON 契约);其余 format 渲染业务数据。
// 复用 internal/output 的权威 Envelope 类型与 WriteEnvelope 出口函数。
func writeDevAppEnvelope(cmd *cobra.Command, result executor.Result) error {
env := &output.Envelope{
OK: true,
Outcome: output.OutcomeSuccess,
Data: devAppEnvelopeData(result),
}
if result.Invocation.DryRun {
env.DryRun = true
} else {
env.Meta, _ = devAppPaginationMeta(env.Data)
}
return writeDevRolloutResult(cmd, devAppCommandResult(result), env, output.FormatJSON)
}
// devAppEnvelopeData 从工具调用结果中提取业务载荷(L2):已实现的
// helper/compat 调用把载荷放在 response.content 下;其余形态(如 dry-run
// 预演的 Result 整体)原样透传,与 output.unwrapCompatRuntimePayload 的
// 解包规则保持一致,保证 data 即既有消费方看到的载荷。
func devAppEnvelopeData(result executor.Result) any {
if result.Invocation.Implemented {
switch result.Invocation.Kind {
case "compat_invocation", "helper_invocation":
if content, ok := result.Response["content"]; ok {
return content
}
}
}
return result
}
// devAppPaginationMeta 把列表载荷里的 cursor 分页字段投影到 meta.pagination
// (契约规范 §3:分页元数据挂 meta 层)。CLI 只观察服务端
// 返回的 hasMore/nextCursor,不做合成。hasMore=true 且带 nextCursor →
// endpoint_exhausted:false + next_token(可续跑);hasMore=false →
// endpoint_exhausted:true。hasMore=true 却无 cursor 时不产出分页元数据,
// 避免违反「endpoint_exhausted:false 必须携带 next_token」。统一结果通过
// devAppDataWithoutPagination 从 data 剥离源控制字段;legacy renderer 不变。
func devAppPaginationMeta(payload any) (*output.Meta, error) {
m, ok := payload.(map[string]any)
if !ok {
return nil, nil
}
rawHasMore, hasFlag := m["hasMore"]
hasMore, hasMoreBool := rawHasMore.(bool)
if hasFlag && !hasMoreBool {
return nil, fmt.Errorf("pagination hasMore must be a JSON boolean")
}
cursor := ""
rawCursor, hasCursor := m["nextCursor"]
if hasCursor {
value, stringOK := rawCursor.(string)
if !stringOK {
return nil, fmt.Errorf("pagination nextCursor must be a JSON string")
}
cursor = strings.TrimSpace(value)
}
if !hasFlag && !hasCursor {
return nil, nil
}
pg := &output.Pagination{}
switch {
case hasMore && cursor != "":
pg.EndpointExhausted = false
pg.NextToken = cursor
case hasFlag && hasMore:
return nil, fmt.Errorf("pagination hasMore=true is missing nextCursor")
case hasFlag && !hasMore:
// DingTalk may echo a terminal cursor even when hasMore=false. The
// boolean is the authoritative exhaustion signal; never expose that
// non-resumable cursor as meta.pagination.next_token.
pg.EndpointExhausted = true
case !hasFlag && hasCursor && cursor != "":
pg.EndpointExhausted = false
pg.NextToken = cursor
default:
return nil, fmt.Errorf("pagination nextCursor is empty without an exhaustion signal")
}
return &output.Meta{Pagination: pg}, nil
}
func devAppDataWithoutPagination(payload any) any {
object, ok := payload.(map[string]any)
if !ok {
return payload
}
data := make(map[string]any, len(object))
for key, value := range object {
switch key {
case "hasMore", "nextCursor":
continue
default:
data[key] = value
}
}
return data
}
func devAppMultiProfileResult(content map[string]any) output.CommandResult {
if !devAppContentBool(content, "multiProfile") {
return nil
}
profiles, ok := content["profiles"].([]any)
if !ok || len(profiles) == 0 {
return nil
}
succeeded := make([]any, 0, len(profiles))
failed := make([]output.PartialFailedEntry, 0)
unknown := make([]output.PartialUnknownEntry, 0)
for i, raw := range profiles {
entry, ok := raw.(map[string]any)
if !ok {
unknown = append(unknown, output.PartialUnknownEntry{
ID: fmt.Sprintf("profile-%d", i+1),
Reason: "profile result is malformed; terminal state cannot be confirmed",
})
continue
}
id := devAppFirstContentString(entry, "selector", "profile")
if id == "" {
id = fmt.Sprintf("profile-%d", i+1)
}
if devAppContentBool(entry, "ok") {
preserved := make(map[string]any, len(entry)+1)
for key, value := range entry {
preserved[key] = value
}
if _, exists := preserved["id"]; !exists {
preserved["id"] = id
}
succeeded = append(succeeded, preserved)
continue
}
errorInfo := &output.ErrorInfo{Type: "api", Message: "profile execution failed"}
if errorMap, ok := entry["error"].(map[string]any); ok {
errorInfo = devAppErrorInfo(errorMap, "profile execution failed")
if category := devAppFirstContentString(errorMap, "type", "category"); category != "" {
errorInfo.Type = devAppWireErrorType(category)
}
errorInfo.Subtype = devAppFirstContentString(errorMap, "subtype", "reason")
errorInfo.Stage = devAppFirstContentString(errorMap, "stage")
errorInfo.Origin = devAppFirstContentString(errorMap, "origin")
errorInfo.Operation = devAppFirstContentString(errorMap, "operation")
errorInfo.RequestID = devAppFirstContentString(errorMap, "request_id", "requestId")
errorInfo.TraceID = devAppFirstContentString(errorMap, "trace_id", "traceId")
errorInfo.Hint = devAppFirstContentString(errorMap, "hint")
if retryable, present := errorMap["retryable"].(bool); present {
errorInfo.Retryable = retryable
}
if executionStarted, present := errorMap["execution_started"].(bool); present {
errorInfo.ExecutionStarted = &executionStarted
}
if actions, present := errorMap["actions"].([]string); present {
errorInfo.Actions = append([]string(nil), actions...)
} else if rawActions, present := errorMap["actions"].([]any); present {
for _, rawAction := range rawActions {
if action, ok := rawAction.(string); ok {
errorInfo.Actions = append(errorInfo.Actions, action)
}
}
}
if details, present := errorMap["details"].(map[string]any); present {
errorInfo.Details = details
}
}
failed = append(failed, output.PartialFailedEntry{ID: id, Error: errorInfo})
}
if len(failed) == 0 && len(unknown) == 0 {
return nil
}
if len(succeeded) == 0 {
details := make([]any, 0, len(failed)+len(unknown))
for _, entry := range failed {
details = append(details, map[string]any{"id": entry.ID, "error": entry.Error})
}
for _, entry := range unknown {
details = append(details, map[string]any{"id": entry.ID, "unknown_reason": entry.Reason})
}
return output.Failure(&output.ErrorInfo{
Type: "api",
Message: "no selected profile has a confirmed success",
Details: map[string]any{"profiles": details},
})
}
partial, err := output.NewPartialData(len(profiles), succeeded, failed, unknown)
if err != nil {
return output.Failure(&output.ErrorInfo{Type: "internal", Message: err.Error()})
}
return output.Partial(partial)
}
func devAppFailureResult(content map[string]any) output.CommandResult {
status := strings.ToUpper(devAppFirstContentString(content, "status", "taskStatus", "versionStatus", "processStatus"))
if rawSuccess, present := content["success"]; present {
success, isBool := rawSuccess.(bool)
if !isBool {
return output.Failure(&output.ErrorInfo{
Type: "api",
Subtype: "invalid_success_type",
Message: "dev response success field must be a JSON boolean",
Hint: "写操作先核查目标状态;读取操作保留脱敏响应证据后排查上游。",
Operation: "devapp.response_projection",
})
}
if !success {
return output.Failure(devAppErrorInfo(content, "dev operation failed"))
}
}
if status == "FAIL" || status == "FAILED" || status == "EXPIRED" {
return output.Failure(devAppErrorInfo(content, "dev operation "+strings.ToLower(status)))
}
return nil
}
func devAppErrorInfo(content map[string]any, fallback string) *output.ErrorInfo {
message := devAppFirstContentString(content, "errorMsg", "errorMessage", "message")
if message == "" {
message = fallback
}
info := &output.ErrorInfo{Type: "api", Message: message}
if code, ok := content["errorCode"]; ok {
info.UpstreamCode = code
} else if code, ok := content["code"]; ok {
info.UpstreamCode = code
}
return info
}
func devAppWireErrorType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "api", "auth", "validation", "permission", "discovery", "internal":
return strings.ToLower(strings.TrimSpace(raw))
case "authorization", "forbidden":
return "permission"
default:
// Upstream transport labels such as network/timeout are subtypes of an
// API operation failure, not new top-level Agent branch keys.
return "api"
}
}
func devAppPendingResult(content map[string]any) output.CommandResult {
state := strings.ToUpper(devAppFirstContentString(content,
"completionState", "status", "taskStatus", "versionStatus", "processStatus", "approvalStatus"))
nonTerminal := !devAppContentBool(content, "terminal") && (devAppContentBool(content, "mustContinue") || devAppContentBool(content, "mustAskUser"))
approvalPending := devAppContentBool(content, "approvalSubmitted") && !devAppContentBool(content, "published")
isPendingState := state == "WAITING" || state == "PENDING" || state == "PROCESSING" || state == "AUDIT" ||
state == "UNDER_REVIEW" || strings.HasPrefix(state, "WAITING_") || strings.HasPrefix(state, "BLOCKED_BY_")
if !isPendingState && !nonTerminal && !approvalPending {
return nil
}
if state == "" {
state = "WAITING_FOR_ACTION"
}
id := devAppFirstContentString(content, "taskId", "versionId", "unifiedAppId", "requestId")
if id == "" {
return output.Failure(&output.ErrorInfo{Type: "internal", Message: "non-terminal dev response is missing an operation identifier"})
}
next := devAppFirstNextCommand(content)
if next == "" {
next = devAppRecoveryCommand(content)
}
if next == "" {
return output.Failure(&output.ErrorInfo{Type: "internal", Message: "non-terminal dev response is missing a recovery command"})
}
return output.Pending(content, &output.OperationInfo{ID: id, State: strings.ToLower(state), NextCommand: next})
}
func devAppRecoveryCommand(content map[string]any) string {
if taskID := devAppFirstContentString(content, "taskId"); taskID != "" {
return fmt.Sprintf("dws dev app robot result --task-id %s --format json", taskID)
}
appID := devAppFirstContentString(content, "unifiedAppId")
versionID := devAppFirstContentString(content, "versionId")
if appID != "" && versionID != "" {
return fmt.Sprintf("dws dev app version status --unified-app-id %s --version-id %s --format json", appID, versionID)
}
return ""
}
func devAppFirstNextCommand(content map[string]any) string {
steps, ok := content["nextSteps"].([]map[string]any)
if ok {
for _, step := range steps {
if command := devAppFirstContentString(step, "command", "dryRunCommand"); command != "" {
return command
}
}
}
if rawSteps, ok := content["nextSteps"].([]any); ok {
for _, raw := range rawSteps {
if step, ok := raw.(map[string]any); ok {
if command := devAppFirstContentString(step, "command", "dryRunCommand"); command != "" {
return command
}
}
}
}
return ""
}
// normalizeDevAppServiceResult unwraps the op-app ServiceResult envelope
@@ -1909,7 +2388,7 @@ func normalizeDevAppVersionApproval(content map[string]any) {
},
devAppNextStep(devAppStep{
ID: "publish_version",
Command: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id %s --approver-user-id <selectedUserId> --yes --format json", unifiedAppID, versionID),
Command: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id %s --approver-user-id <selectedUserId> --format json", unifiedAppID, versionID),
DryRunCommand: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id %s --approver-user-id <selectedUserId> --dry-run --format json", unifiedAppID, versionID),
DoneWhen: "approvalSubmitted=true、versionStatus=AUDIT 或 processStatus=UNDER_REVIEW 表示已提交审批;published=true 表示已发布",
Blocking: true,
@@ -2031,7 +2510,7 @@ func devAppRobotPublishSteps(appID string) []map[string]any {
steps := []map[string]any{
devAppNextStep(devAppStep{
ID: "create_version",
Command: fmt.Sprintf("dws dev app version create --unified-app-id %s --desc \"发布机器人能力\" --yes --format json", appID),
Command: fmt.Sprintf("dws dev app version create --unified-app-id %s --desc \"发布机器人能力\" --format json", appID),
DryRunCommand: fmt.Sprintf("dws dev app version create --unified-app-id %s --desc \"发布机器人能力\" --dry-run --format json", appID),
DoneWhen: "返回 versionId",
Blocking: true,
@@ -2044,7 +2523,7 @@ func devAppRobotPublishSteps(appID string) []map[string]any {
}),
devAppNextStep(devAppStep{
ID: "publish_version",
Command: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id <versionId> --yes --format json", appID),
Command: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id <versionId> --format json", appID),
DryRunCommand: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id <versionId> --dry-run --format json", appID),
DoneWhen: "published=true 表示已发布;approvalSubmitted=true、versionStatus=AUDIT 或 processStatus=UNDER_REVIEW 表示已提交审批;SELECT_APPROVER 时必须先让用户从 approvalCandidates 选择审批人后追加 --approver-user-id",
RequiresUserInput: true,
@@ -2095,7 +2574,7 @@ func devAppRobotRetryStep(taskID string, reuseTaskID bool) map[string]any {
}
return devAppNextStep(devAppStep{
ID: "retry_robot_submit",
Command: fmt.Sprintf("dws dev app robot submit --name <name> --robot-name <robotName> --desc <desc>%s --yes --format json", taskIDFlag),
Command: fmt.Sprintf("dws dev app robot submit --name <name> --robot-name <robotName> --desc <desc>%s --format json", taskIDFlag),
DryRunCommand: fmt.Sprintf("dws dev app robot submit --name <name> --robot-name <robotName> --desc <desc>%s --dry-run --format json", taskIDFlag),
DoneWhen: "返回新的 WAITING taskId;FAIL 场景优先复用原 taskId,EXPIRED 场景重新提交",
Blocking: true,
+30 -6
View File
@@ -22,8 +22,10 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
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/output"
"github.com/spf13/cobra"
)
@@ -417,11 +419,9 @@ func connectLocalDebugNotice() string {
return "[connect] 提示:本地调试,不代表线上发布完成;dev connect 只建立本地 Stream,不会提交版本发布。若机器人来自 APPROVAL_REQUIRED,仍需继续执行 version create → check-approval → publish → status。\n"
}
// connectPreviewEnvelope wraps a connect dry-run preview in an envelope that
// mirrors the app-tree helper_invocation shape (kind + dry_run at a known top
// level), so an agent can parse "is this a dry-run preview" the same way across
// all dev commands. The connect-specific fields (channel/cli/connect/...) sit
// inside, since connect is a linking pre-check, not an MCP tool call.
// connectPreviewEnvelope preserves the established streaming-command preview
// shape. `dev connect` remains legacy until a dedicated streaming contract is
// available; terminal child commands migrate independently.
func connectPreviewEnvelope(fields map[string]any) map[string]any {
fields["kind"] = "connect_preview"
fields["dry_run"] = true
@@ -489,6 +489,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
if err != nil {
return err
}
daemonMode, _ := cmd.Flags().GetBool(daemonFlag)
// Credential resolution: explicit pair wins; otherwise reuse dev app's
// credentials get against --unified-app-id.
@@ -558,7 +559,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
// --daemon: detach into a background supervisor that keeps the
// 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 {
if daemonMode {
notifyStaffID := devAppStringFlag(cmd, "notify-staff-id")
profile, _ := cmd.Root().PersistentFlags().GetString("profile")
alwaysOn, _ := cmd.Flags().GetBool("alwayson")
@@ -614,6 +615,29 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
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")
DeclareLeafMetadata(cmd, LeafSpec{
// Foreground connect is a long-lived stream and remains legacy until a
// dedicated streaming contract exists. Terminal child commands migrate
// independently.
OutputRollout: output.RolloutLegacyOnly,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "dev", Name: "connect", CanonicalPath: "dev.connect",
CLIPath: "dev connect", PrimaryCLIPath: "dev connect",
},
Description: "把现有机器人连接到本地 agent;该命令同时承载前台流与 --daemon,整体暂留 legacy,终态子命令独立迁移",
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "命令组合远端凭证获取、Stream 建连与本地守护进程管理,不对应单一 MCP 接口"},
Selection: contract.SelectionSpec{
AgentSummary: "把现有机器人连接到本地 agent 进行调试",
UseWhen: []string{"需要启动本地机器人 Stream 调试连接"},
AvoidWhen: []string{"创建或发布应用版本时使用 dev app"},
Examples: []string{"dws dev connect --daemon --unified-app-id <unifiedAppId>"},
},
},
})
return cmd
}
@@ -0,0 +1,98 @@
// 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"
"os"
"strings"
"testing"
)
func TestDevConnectListPreservesPublishedTableAndJSONArray(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
healthyDir, err := connectDaemonDir(daemonDirKey("dingAAA", ""))
if err != nil {
t.Fatalf("connectDaemonDir(healthy): %v", err)
}
writeJSON(t, connectHeartbeatPath(healthyDir), connectHeartbeat{
Pid: os.Getpid(), Channel: "codex", ClientID: "dingAAA",
StartUnix: 1_000_000, ConnectedUnix: 1_000_010, UpdatedUnix: 2_000_000,
})
downDir, err := connectDaemonDir(daemonDirKey("dingBBB", ""))
if err != nil {
t.Fatalf("connectDaemonDir(down): %v", err)
}
writeJSON(t, connectHeartbeatPath(downDir), connectHeartbeat{
Pid: deadPid(t), Channel: "opencode", ClientID: "dingBBB",
StartUnix: 1_000_000, ConnectedUnix: 1_000_010, UpdatedUnix: 2_000_000,
})
root := newDevAppTestRoot(&captureRunner{})
tableOut, tableErr, err := runRootBuffered(t, root, "dev", "connect", "list")
if err != nil {
t.Fatalf("connect list error = %v\nstderr:\n%s", err, tableErr.String())
}
table := tableOut.String()
for _, value := range []string{"STATE", "CLIENT", "PID", "CHANNEL", "dingAAA", "dingBBB", "codex", "opencode"} {
if !strings.Contains(table, value) {
t.Fatalf("legacy table missing %q:\n%s", value, table)
}
}
if strings.Contains(table, `"outcome"`) || strings.Contains(table, `"ok"`) {
t.Fatalf("legacy table was enveloped:\n%s", table)
}
jsonRoot := newDevAppTestRoot(&captureRunner{})
jsonOut, jsonErr, err := runRootBuffered(t, jsonRoot, "dev", "connect", "list", "--json")
if err != nil {
t.Fatalf("connect list --json error = %v\nstderr:\n%s", err, jsonErr.String())
}
var reports []connectHealthReport
if err := json.Unmarshal(jsonOut.Bytes(), &reports); err != nil {
t.Fatalf("legacy --json is not a top-level array: %v\n%s", err, jsonOut.String())
}
if len(reports) != 2 || strings.Contains(jsonOut.String(), `"outcome"`) || strings.Contains(jsonOut.String(), `"data"`) {
t.Fatalf("legacy --json changed shape: %s", jsonOut.String())
}
}
func TestDevConnectListPreservesPublishedEmptyState(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
root := newDevAppTestRoot(&captureRunner{})
humanOut, humanErr, err := runRootBuffered(t, root, "dev", "connect", "list")
if err != nil {
t.Fatalf("connect list empty error = %v\nstderr:\n%s", err, humanErr.String())
}
if strings.TrimSpace(humanOut.String()) != "no connectors found" {
t.Fatalf("legacy empty output=%q", humanOut.String())
}
jsonRoot := newDevAppTestRoot(&captureRunner{})
jsonOut, jsonErr, err := runRootBuffered(t, jsonRoot, "dev", "connect", "list", "--json")
if err != nil {
t.Fatalf("connect list empty --json error = %v\nstderr:\n%s", err, jsonErr.String())
}
var reports []connectHealthReport
if err := json.Unmarshal(jsonOut.Bytes(), &reports); err != nil {
t.Fatalf("legacy empty --json is not an array: %v\n%s", err, jsonOut.String())
}
if reports == nil || len(reports) != 0 {
t.Fatalf("legacy empty --json=%s, want []", jsonOut.String())
}
}
@@ -0,0 +1,202 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"bytes"
"context"
"encoding/json"
"os"
"strings"
"testing"
"github.com/spf13/cobra"
)
// phaseFConnectRoot 构造 stdout/stderr 分流的可观测命令树根,供 connect 族
// 流纪律断言使用(统一输出 dev 域试点,队列 B108/B109/B116)。
func phaseFConnectRoot(t *testing.T, runner *captureRunner) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
root := newDevAppTestRoot(runner)
var stdout, stderr bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&stderr)
root.SetIn(strings.NewReader(""))
return root, &stdout, &stderr
}
type phaseFEnvelope struct {
OK bool `json:"ok"`
Outcome string `json:"outcome"`
DryRun bool `json:"dry_run"`
Data map[string]any `json:"data"`
}
func decodePhaseFEnvelope(t *testing.T, raw []byte) *phaseFEnvelope {
t.Helper()
var env phaseFEnvelope
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("stdout is not a single valid JSON envelope: %v\n%s", err, raw)
}
if (env.Outcome == "success" || env.Outcome == "pending") && !env.OK {
t.Fatalf("envelope invariant I1 violated: ok=false with outcome=%q\n%s", env.Outcome, raw)
}
return &env
}
// decodePhaseFConnectPreview parses the established legacy preview. The
// streaming root remains legacy while terminal children migrate independently.
func decodePhaseFConnectPreview(t *testing.T, raw []byte) map[string]any {
t.Helper()
var document map[string]any
if err := json.Unmarshal(raw, &document); err != nil {
t.Fatalf("stdout is not a single valid JSON document: %v\n%s", err, raw)
}
preview, ok := document["invocation"].(map[string]any)
if !ok {
t.Fatalf("legacy connect preview missing invocation: %s", raw)
}
return preview
}
// TestDevConnectCustomChannelPreviewEnvelope 是队列 B108:--agent-cmd 自定义
// 渠道(custom)的 dry-run 预览信封断言。--agent-cmd 是 custom 渠道的语法糖:
// 未显式 --channel 时强制 custom,预览保持原有 invocation JSON shape。
func TestDevConnectCustomChannelPreviewEnvelope(t *testing.T) {
clearChannelEnv(t)
// RunE 内部用 os.Setenv 写 DWS_AGENT_CMD(不随 t.Setenv 恢复),显式清理防串测。
t.Cleanup(func() { os.Unsetenv("DWS_AGENT_CMD") })
root, stdout, stderr := phaseFConnectRoot(t, &captureRunner{})
root.SetArgs([]string{
"dev", "connect",
"--agent-cmd", "lobster -p",
"--robot-client-id", "cid-1",
"--robot-client-secret", "sec-1",
"--dry-run",
})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
}
data := decodePhaseFConnectPreview(t, stdout.Bytes())
if data == nil {
t.Fatalf("preview envelope data is nil: %s", stdout.String())
}
if data["channel"] != "custom" {
t.Fatalf("channel = %#v, want custom (--agent-cmd 糖强制 custom): %s", data["channel"], stdout.String())
}
if data["kind"] != "connect_preview" || data["scope"] != "local_debug_only" {
t.Fatalf("preview markers kind/scope = %#v/%#v: %s", data["kind"], data["scope"], stdout.String())
}
if data["clientId"] != "cid-1" {
t.Fatalf("clientId = %#v, want cid-1: %s", data["clientId"], stdout.String())
}
if cred, _ := data["credentialSource"].(string); !strings.HasPrefix(cred, "flag:") {
t.Fatalf("credentialSource = %#v, want flag:* 前缀: %s", data["credentialSource"], stdout.String())
}
if data["terminal"] != false || data["doesNotPublish"] != true {
t.Fatalf("terminal/doesNotPublish = %#v/%#v, want false/true: %s",
data["terminal"], data["doesNotPublish"], stdout.String())
}
if _, ok := data["connect"].(map[string]any); !ok {
t.Fatalf("connect plan missing from preview data: %s", stdout.String())
}
// secret 落 argv 的人读警告只走 stderr;stdout 不得混入人读文案。
if !strings.Contains(stderr.String(), "--robot-client-secret 出现在命令行") {
t.Fatalf("argv secret warning missing from stderr: %q", stderr.String())
}
if strings.Contains(stdout.String(), "[connect]") {
t.Fatalf("stdout must carry only the envelope, human-readable text leaked: %s", stdout.String())
}
}
// TestDevConnectDryRunUnifiedAppIDPreviewEndToEnd 是队列 B116 的 dry-run
// 端到端分支:--unified-app-id 路径的 dry-run 必须跳过 credentials get
// (runner 零调用),预览信封如实标注凭证来源为「unified-app-id(skipped)」。
func TestDevConnectDryRunUnifiedAppIDPreviewEndToEnd(t *testing.T) {
clearChannelEnv(t)
runner := &captureRunner{}
root, stdout, stderr := phaseFConnectRoot(t, runner)
root.SetArgs([]string{
"dev", "connect",
"--channel", "hermes",
"--unified-app-id", "u-9",
"--dry-run",
})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
}
data := decodePhaseFConnectPreview(t, stdout.Bytes())
if data == nil {
t.Fatalf("preview envelope data is nil: %s", stdout.String())
}
if data["channel"] != "hermes" {
t.Fatalf("channel = %#v, want hermes: %s", data["channel"], stdout.String())
}
if data["unifiedAppId"] != "u-9" {
t.Fatalf("unifiedAppId = %#v, want u-9: %s", data["unifiedAppId"], stdout.String())
}
if data["credentialSource"] != "unified-app-id (credentials get, skipped in dry-run)" {
t.Fatalf("credentialSource = %#v, want dry-run skipped marker: %s", data["credentialSource"], stdout.String())
}
// dry-run 禁止真实调用 credentials get。
if runner.last.Tool != "" {
t.Fatalf("dry-run must not invoke any tool, got %q", runner.last.Tool)
}
}
// TestDevConnectForegroundStdoutDiscipline 是队列 B109:前台启动路径的
// channel/凭证来源提示行与本地调试声明保持 stderr,stdout 严格零字节
// (契约规范 §5.1:stdout 只承载数据;前台 connector 的后续输出由被 stub 的
// stream 层承载,本测试验证 dws 自身不向 stdout 写任何人读文案)。
func TestDevConnectForegroundStdoutDiscipline(t *testing.T) {
clearChannelEnv(t)
t.Setenv("DWS_CONNECT_CMD", "")
t.Setenv("DWS_AGENT_CMD", "sh -c printf\\ ok")
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
origStream := devAppRunStreamConnector
t.Cleanup(func() { devAppRunStreamConnector = origStream })
devAppRunStreamConnector = func(context.Context, string, string, string, forwarder, *aiCardClient, *connectExtras) error {
return nil
}
root, stdout, stderr := phaseFConnectRoot(t, &captureRunner{})
root.SetArgs([]string{
"dev", "connect",
"--channel", "custom",
"--robot-client-id", "cid-1",
"--robot-client-secret", "sec-1",
})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
}
if stdout.Len() != 0 {
t.Fatalf("foreground connect must keep stdout empty, got %q", stdout.String())
}
errText := stderr.String()
for _, want := range []string{
"[connect] channel=custom",
"凭证来源=flag:--robot-client-id/--robot-client-secret",
"不代表线上发布完成",
} {
if !strings.Contains(errText, want) {
t.Fatalf("stderr missing %q:\n%s", want, errText)
}
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ import (
// registerDevAppCursorFlags adds the two cursor flags every list/search command
// exposes. pageSize defaults to 20.
func registerDevAppCursorFlags(cmd *cobra.Command) {
cmd.Flags().String("cursor", "", "游标令牌:首次查询留空,续翻传上次出参的 nextCursor")
cmd.Flags().String("cursor", "", "游标令牌:首次查询留空,续翻传上次 meta.pagination.next_token")
cmd.Flags().Int("page-size", 20, "单页条数,默认 20")
}
@@ -0,0 +1,212 @@
// 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 (
"reflect"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/spf13/cobra"
)
// collectDevLeafCommands 递归收集命令树中所有叶子命令(无子命令者)。
func collectDevLeafCommands(cmd *cobra.Command, out *[]*cobra.Command) {
if cmd == nil {
return
}
if len(cmd.Commands()) == 0 {
*out = append(*out, cmd)
return
}
for _, sub := range cmd.Commands() {
collectDevLeafCommands(sub, out)
}
}
func TestDevRepresentativeResultContractsReachContractFinal(t *testing.T) {
root := newDevAppTestRoot(&captureRunner{})
var leaves []*cobra.Command
collectDevLeafCommands(root, &leaves)
finals := make(map[string]contract.ContractFinalPayload)
for _, leaf := range leaves {
final, ok := contractfinal.RuntimeContractFinal(leaf)
if ok && final.Identity != nil {
finals[final.Identity.CanonicalPath] = final
}
}
paginationCheck := func(t *testing.T, final contract.ContractFinalPayload) {
t.Helper()
if final.Pagination == nil || final.Pagination.Kind != contract.PaginationKindCursor ||
final.Pagination.CursorParameter != "cursor" || final.Pagination.MetaPath != contract.PaginationMetaPath {
t.Fatalf("paginated list contract = %#v", final)
}
}
tests := []struct {
canonical string
outcomes []contract.ResultOutcome
check func(*testing.T, contract.ContractFinalPayload)
}{
{
canonical: "dev.list_dev_app",
outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
check: paginationCheck,
},
{canonical: "dev.list_dev_app_permissions", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, check: paginationCheck},
{canonical: "dev.list_dev_app_events", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, check: paginationCheck},
{canonical: "dev.list_dev_app_versions", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, check: paginationCheck},
{canonical: "dev.get_dev_app", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}},
{
canonical: "dev.get_dev_app_credentials",
outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
check: func(t *testing.T, final contract.ContractFinalPayload) {
result := final.Result
if want := []string{"appSecret", "clientSecret"}; !reflect.DeepEqual(result.SensitivePaths, want) {
t.Fatalf("sensitive paths = %#v, want %#v", result.SensitivePaths, want)
}
},
},
{canonical: "dev.get_dev_app_version_status", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomePending, contract.ResultOutcomeFailure}},
{canonical: "dev.connect_status", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}},
{canonical: "dev.connect_list", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}},
}
for _, test := range tests {
t.Run(test.canonical, func(t *testing.T) {
final, ok := finals[test.canonical]
if !ok || final.Result == nil {
t.Fatalf("representative leaf %s has no final Result", test.canonical)
}
if !reflect.DeepEqual(final.Result.Outcomes, test.outcomes) {
t.Fatalf("outcomes = %#v, want %#v", final.Result.Outcomes, test.outcomes)
}
if len(final.Result.DataSchema) == 0 {
t.Fatal("data_schema is empty")
}
if test.check != nil {
test.check(t, final)
}
})
}
}
// TestDevLeafDeclarationCompleteness 是队列 B190 的 dev 叶子声明完整性自查:
// 每一个**注册了 ContractFinal** 的 dev 叶子都必须满足 Identity+Safety 最小集
// (无半壳:有注册但字段缺失)。对未注册 ContractFinal 的叶子(裸 Cobra 迁移态
// 或 NewLeafCommand 漏声明)不硬失败,而是汇总为 findings 清单打印,供人工判断
// 是否属「空壳入册」。
func TestDevLeafDeclarationCompleteness(t *testing.T) {
root := newDevAppTestRoot(&captureRunner{})
var leaves []*cobra.Command
collectDevLeafCommands(root, &leaves)
if len(leaves) == 0 {
t.Fatalf("no dev leaves collected under test root")
}
t.Logf("dev domain leaves under test: %d", len(leaves))
var noFinal []string
for _, leaf := range leaves {
path := leaf.CommandPath()
final, ok := contractfinal.RuntimeContractFinal(leaf)
if !ok {
noFinal = append(noFinal, path)
continue
}
t.Run(path, func(t *testing.T) {
if final.Identity == nil {
t.Fatalf("leaf %q ContractFinal.Identity is nil", path)
}
if final.Identity.ProductID == "" {
t.Fatalf("leaf %q Identity.ProductID empty", path)
}
if final.Identity.Name == "" {
t.Fatalf("leaf %q Identity.Name empty", path)
}
if final.Identity.CanonicalPath == "" {
t.Fatalf("leaf %q Identity.CanonicalPath empty", path)
}
if final.Safety == nil {
t.Fatalf("leaf %q ContractFinal.Safety is nil (Safety minimal set missing)", path)
}
if final.Description == "" {
t.Fatalf("leaf %q ContractFinal.Description empty (no description declared)", path)
}
})
}
if len(noFinal) > 0 {
sort.Strings(noFinal)
t.Logf("B190 findings: %d dev leaves without ContractFinal (may be intentional bare-Cobra migration or empty-shell NewLeafCommand):", len(noFinal))
for _, p := range noFinal {
t.Logf(" - %s", p)
}
}
}
// TestDevLeafDeclarationNoEmptyShellNewLeaf 是队列 B190 的核心断言:NewLeafCommand
// 完全托管的叶子必须注册 ContractFinal——声明了 LeafSpec 却无 ContractFinal 就是
// 「空壳入册」。本测试通过 Verify 一个已知的既存漏洞(version check-approval 用
// NewLeafCommand 但 LeafSpec 未声明 Contract)来固化当前现状,并断言这类「声明了
// Shell 却无最终契约」的问题在受控已知集中,而非蔓延到其它托管叶子。
func TestDevLeafDeclarationNoEmptyShellNewLeaf(t *testing.T) {
root := newDevAppTestRoot(&captureRunner{})
var leaves []*cobra.Command
collectDevLeafCommands(root, &leaves)
knownEmptyShell := map[string]bool{}
var unexpected []string
for _, leaf := range leaves {
path := leaf.CommandPath()
if _, ok := contractfinal.RuntimeContractFinal(leaf); ok {
continue // 正常托管叶子已有 ContractFinal。
}
// 无 ContractFinal:裸 Cobra 迁移态(connect 系列)或已知空壳。
if knownEmptyShell[path] {
continue
}
// 裸 Cobra 命令(不经 NewLeafCommand)不要求 ContractFinal,排除。
if isBareDevCobraLeaf(leaf) {
continue
}
unexpected = append(unexpected, path)
}
if len(unexpected) > 0 {
t.Fatalf("unexpected empty-shell NewLeafCommand leaves without ContractFinal: %v", unexpected)
}
}
// isBareDevCobraLeaf 判断某 dev 叶子是否为裸 Cobra 命令(不经 NewLeafCommand,
// 不注册 ContractFinal)。通过多次构造命令并检查其是否有本地 flags 来间接识别
// 不可靠,改用命令名白名单:connect 本地运维子命令为已知裸 Cobra 迁移态。
func isBareDevCobraLeaf(cmd *cobra.Command) bool {
switch cmd.Name() {
case "list", "status", "restart", "stop":
// 仅当命令位于 dev connect 子树下才算裸 Cobra(避免误伤 dev app 的
// version list 等托管叶子)。
if strings.Contains(cmd.CommandPath(), "dev connect") {
return true
}
}
return false
}
var (
_ = contractfinal.HasRuntimeContractFinal
_ = sort.Strings
)
@@ -0,0 +1,177 @@
// 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"
"sort"
"strings"
"testing"
"github.com/spf13/cobra"
)
// devAppRegressionLeafPaths 遍历 dev app 命令树,收集全部叶子路径(无子命令
// 即叶子)。核销清单的「清单 ⇆ 命令树」双向绑定依赖它:新增叶子若不在清单
// 里,清单测试失败,强制信封覆盖随叶子同批落盘(AC-28/M1.8)。
func devAppRegressionLeafPaths(t *testing.T) []string {
t.Helper()
root := newDevAppTestRoot(devAppFamilyContentRunner(map[string]any{}))
appCmd, _, err := root.Find([]string{"dev", "app"})
if err != nil {
t.Fatalf("Find(dev app) error = %v", err)
}
var leaves []string
var traverse func(c *cobra.Command, prefix []string)
traverse = func(c *cobra.Command, prefix []string) {
if !c.HasSubCommands() {
leaves = append(leaves, strings.Join(prefix, " "))
return
}
for _, sub := range c.Commands() {
traverse(sub, append(append([]string{}, prefix...), sub.Name()))
}
}
traverse(appCmd, nil)
sort.Strings(leaves)
return leaves
}
// devAppEnvelopeRegressionChecklist 是 dev app 树 32 叶子的信封核销清单
// (队列 B138,AC-28/M1.8):每条给出可执行 argv。读叶子走成功路径
// (outcome=success),写叶子带 --dry-run 走预览路径(outcome=success +
// dry_run=true)。argv 与轮 11-B0 族验证测试同源(各叶子 flag 已逐一核对)。
func devAppEnvelopeRegressionChecklist() []struct {
path string
args []string
write bool
} {
return []struct {
path string
args []string
write bool
}{
{"list", []string{"dev", "app", "list", "--name", "DemoApp"}, false},
{"get", []string{"dev", "app", "get", "--unified-app-id", "u-1"}, false},
{"create", []string{"dev", "app", "create", "--name", "DemoApp", "--dry-run"}, true},
{"update", []string{"dev", "app", "update", "--unified-app-id", "u-1", "--name", "NewName", "--dry-run"}, true},
{"delete", []string{"dev", "app", "delete", "--unified-app-id", "u-1", "--dry-run"}, true},
{"disable", []string{"dev", "app", "disable", "--unified-app-id", "u-1", "--dry-run"}, true},
{"enable", []string{"dev", "app", "enable", "--unified-app-id", "u-1", "--dry-run"}, true},
{"credentials get", []string{"dev", "app", "credentials", "get", "--unified-app-id", "u-1"}, false},
{"webapp get", []string{"dev", "app", "webapp", "get", "--unified-app-id", "u-1"}, false},
{"webapp config", []string{"dev", "app", "webapp", "config", "--unified-app-id", "u-1", "--homepage-url", "https://example.com", "--dry-run"}, true},
{"permission list", []string{"dev", "app", "permission", "list", "--unified-app-id", "u-1"}, false},
{"permission add", []string{"dev", "app", "permission", "add", "--unified-app-id", "u-1", "--scope-values", "Contact.User.mobile", "--dry-run"}, true},
{"permission remove", []string{"dev", "app", "permission", "remove", "--unified-app-id", "u-1", "--scope-values", "Contact.User.mobile", "--dry-run"}, true},
{"member list", []string{"dev", "app", "member", "list", "--unified-app-id", "u-1"}, false},
{"member add", []string{"dev", "app", "member", "add", "--unified-app-id", "u-1", "--user-ids", "user-1", "--member-type", "DEVELOPER", "--dry-run"}, true},
{"member remove", []string{"dev", "app", "member", "remove", "--unified-app-id", "u-1", "--user-ids", "user-1", "--member-type", "DEVELOPER", "--dry-run"}, true},
{"security config", []string{"dev", "app", "security", "config", "--unified-app-id", "u-1", "--redirect-urls", "https://cb.example.invalid/cb", "--dry-run"}, true},
{"robot submit", []string{"dev", "app", "robot", "submit", "--name", "智能体", "--robot-name", "小助手", "--desc", "审批问答", "--dry-run"}, true},
{"robot result", []string{"dev", "app", "robot", "result", "--task-id", "t-1"}, false},
{"robot get", []string{"dev", "app", "robot", "get", "--unified-app-id", "u-1"}, false},
{"robot config", []string{"dev", "app", "robot", "config", "--unified-app-id", "u-1", "--name", "小助手", "--dry-run"}, true},
{"robot enable", []string{"dev", "app", "robot", "enable", "--unified-app-id", "u-1", "--dry-run"}, true},
{"robot disable", []string{"dev", "app", "robot", "disable", "--unified-app-id", "u-1", "--dry-run"}, true},
{"version create", []string{"dev", "app", "version", "create", "--unified-app-id", "u-1", "--desc", "新增机器人", "--dry-run"}, true},
{"version list", []string{"dev", "app", "version", "list", "--unified-app-id", "u-1"}, false},
{"version get", []string{"dev", "app", "version", "get", "--unified-app-id", "u-1", "--version-id", "v-1"}, false},
{"version check-approval", []string{"dev", "app", "version", "check-approval", "--unified-app-id", "u-1", "--version-id", "v-1"}, false},
{"version publish", []string{"dev", "app", "version", "publish", "--unified-app-id", "u-1", "--version-id", "v-1", "--dry-run"}, true},
{"version status", []string{"dev", "app", "version", "status", "--unified-app-id", "u-1", "--version-id", "v-1"}, false},
{"event list", []string{"dev", "app", "event", "list", "--unified-app-id", "u-1"}, false},
{"event subscribe", []string{"dev", "app", "event", "subscribe", "--unified-app-id", "u-1", "--event-codes", "a,b", "--dry-run"}, true},
{"event unsubscribe", []string{"dev", "app", "event", "unsubscribe", "--unified-app-id", "u-1", "--event-codes", "a,b", "--dry-run"}, true},
}
}
// TestDevAppEnvelopeRegressionLeafInventory 是核销清单的清单 ⇆ 命令树双向
// 绑定:dev app 树叶子集合必须与清单完全一致。新增叶子未补信封断言、或
// 清单残留已删除叶子,都会在此失败。
func TestDevAppEnvelopeRegressionLeafInventory(t *testing.T) {
tree := devAppRegressionLeafPaths(t)
want := make([]string, 0)
for _, entry := range devAppEnvelopeRegressionChecklist() {
want = append(want, entry.path)
}
sort.Strings(want)
if len(tree) != len(want) {
t.Fatalf("leaf count = %d, checklist = %d\ntree: %v\nchecklist: %v",
len(tree), len(want), tree, want)
}
for i := range tree {
if tree[i] != want[i] {
t.Fatalf("leaf[%d] = %q, checklist = %q\ntree: %v\nchecklist: %v",
i, tree[i], want[i], tree, want)
}
}
}
// regressionEnvelope 是核销断言的信封解码形状:ok 用 *bool——缺席(未走
// 统一出口)为 nil,`"ok":"true"` 字符串形态(违反 AC-02)直接解码失败。
type regressionEnvelope struct {
OK *bool `json:"ok"`
Outcome string `json:"outcome"`
DryRun bool `json:"dry_run"`
Data map[string]any `json:"data"`
}
// TestDevAppEnvelopeRegressionUnifiedExit 逐一执行 32 叶子并断言 stdout 恰为
// 一个统一信封(走 writeDevAppEnvelope 唯一出口):ok 为 JSON 布尔 true、
// outcome 与读/写形态一致、data 非空。任何绕过信封直写 stdout 的叶子
// (legacy 裸 JSON/人读文案)都会因缺 ok 键或 JSON 解析失败而暴露。
func TestDevAppEnvelopeRegressionUnifiedExit(t *testing.T) {
for _, entry := range devAppEnvelopeRegressionChecklist() {
t.Run(entry.path, func(t *testing.T) {
out, errBuf, err := runDevAppFamily(t,
devAppFamilyContentRunner(map[string]any{
"unifiedAppId": "u-1",
"name": "DemoApp",
"appStatus": "ENABLED",
}),
entry.args...)
if err != nil {
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
}
var env regressionEnvelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("stdout is not a single JSON envelope (unified exit bypass?): %v\n%s", err, out.String())
}
if env.OK == nil {
t.Fatalf("envelope missing ok key (unified exit bypass?): %s", out.String())
}
if !*env.OK {
t.Fatalf("ok = false on success/pending path (I1 violated): %s", out.String())
}
if entry.write {
if env.Outcome != "success" || !env.DryRun {
t.Fatalf("write leaf dry-run outcome/dry_run = %q/%v, want success/true: %s",
env.Outcome, env.DryRun, out.String())
}
} else {
if env.Outcome != "success" || env.DryRun {
t.Fatalf("read leaf outcome/dry_run = %q/%v, want success/false: %s",
env.Outcome, env.DryRun, out.String())
}
}
if env.Data == nil {
t.Fatalf("envelope data is nil: %s", out.String())
}
})
}
}
+275
View File
@@ -0,0 +1,275 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"bytes"
stderrors "errors"
"os"
"path/filepath"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
)
// runDevDomainErrorCase 在生产口径的静默根下执行错误路径用例(队列
// B128~B137)。生产根命令设 SilenceUsage/SilenceErrors=true
// (internal/app/root.go),错误时 Cobra 不向 stdout 打 usage/error;测试根
// 对齐同一口径,使「错误路径 stdout 零字节」断言与真实调用一致。
func runDevDomainErrorCase(t *testing.T, runner executor.Runner, args ...string) (*bytes.Buffer, *bytes.Buffer, error) {
t.Helper()
root := newDevAppTestRoot(runner)
root.SilenceUsage = true
root.SilenceErrors = true
var out, errBuf bytes.Buffer
root.SetOut(&out)
root.SetErr(&errBuf)
root.SetIn(strings.NewReader(""))
root.SetArgs(args)
err := root.Execute()
return &out, &errBuf, err
}
// requireDevValidationError 断言错误是结构化 apperrors validation(AC-03):
// Category=validation + rc=3 + 消息可定位。
func requireDevValidationError(t *testing.T, err error, wantMsg string) *apperrors.Error {
t.Helper()
if err == nil {
t.Fatalf("error = nil, want structured validation error containing %q", wantMsg)
}
var appErr *apperrors.Error
if !stderrors.As(err, &appErr) {
t.Fatalf("error type = %T (%v), want structured *errors.Error", err, err)
}
if appErr.Category != apperrors.CategoryValidation {
t.Fatalf("category = %q, want validation", appErr.Category)
}
if got := apperrors.ExitCode(err); got != 3 {
t.Fatalf("exit code = %d, want 3 (validation)", got)
}
if !strings.Contains(err.Error(), wantMsg) {
t.Fatalf("message = %q, want contains %q", err.Error(), wantMsg)
}
return appErr
}
// TestDevAppGetMissingLocatorStructuredValidation 是队列 B128:dev app get
// 无参数二选一报结构化 validation(rc=3),stdout 零字节。
func TestDevAppGetMissingLocatorStructuredValidation(t *testing.T) {
out, _, err := runDevDomainErrorCase(t, &captureRunner{}, "dev", "app", "get")
requireDevValidationError(t, err, "请传入 --unified-app-id 或 --app-key")
if out.Len() != 0 {
t.Fatalf("error path must keep stdout empty, got %q", out.String())
}
}
// TestDevConnectUnknownChannelStructuredValidation 是队列 B129:dev connect
// 未知渠道报结构化 validation(NewValidation,rc=3),stdout 零字节。
func TestDevConnectUnknownChannelStructuredValidation(t *testing.T) {
clearChannelEnv(t)
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
"dev", "connect", "--channel", "bogus")
requireDevValidationError(t, err, `未知渠道 "bogus"`)
if out.Len() != 0 {
t.Fatalf("error path must keep stdout empty, got %q", out.String())
}
}
// TestDevConnectMissingCredentialsNonInteractiveStructuredValidation 是队列
// B130:非交互环境缺凭证不进引导流程,直接报结构化 validation(rc=3)。
// stdin 交互判定经 seam 显式置 false,保证脚本/CI 环境断言确定性。
func TestDevConnectMissingCredentialsNonInteractiveStructuredValidation(t *testing.T) {
clearChannelEnv(t)
origInteractive := devAppConnectStdinInteractive
t.Cleanup(func() { devAppConnectStdinInteractive = origInteractive })
devAppConnectStdinInteractive = func() bool { return false }
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
"dev", "connect", "--channel", "hermes")
requireDevValidationError(t, err, "需要 --robot-client-id/--robot-client-secret")
if out.Len() != 0 {
t.Fatalf("error path must keep stdout empty, got %q", out.String())
}
}
// TestDevConnectRestartMissingRecordStructuredValidation 是队列 B131 的结构化
// 增量:restart 无连接器记录报结构化 validation(rc=3)。错误消息与 stdout 零
// 字节断言已由轮 11-B0 B116(TestConnectDaemonFamilyMissingDaemonErrorPaths)
// 覆盖,本测试补 AC-03 的 Category/rc 映射面。
func TestDevConnectRestartMissingRecordStructuredValidation(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
"dev", "connect", "restart", "--robot-client-id", "ghost")
requireDevValidationError(t, err, "未找到连接器记录")
if out.Len() != 0 {
t.Fatalf("error path must keep stdout empty, got %q", out.String())
}
}
// TestDevConnectStatusDaemonDirResolutionFailure 是队列 B133:status 的
// daemon 目录解析失败(override 指向普通文件使 MkdirAll 失败)报结构化
// internal 错误(rc=5),stdout 零字节。
func TestDevConnectStatusDaemonDirResolutionFailure(t *testing.T) {
blocked := filepath.Join(t.TempDir(), "blocked")
if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil {
t.Fatalf("setup blocked file: %v", err)
}
connectDaemonDirOverride = blocked
t.Cleanup(func() { connectDaemonDirOverride = "" })
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
"dev", "connect", "status", "--robot-client-id", "ghost", "--json")
if err == nil {
t.Fatalf("error = nil, want daemon dir resolution failure")
}
var appErr *apperrors.Error
if !stderrors.As(err, &appErr) {
t.Fatalf("error type = %T (%v), want structured *errors.Error", err, err)
}
if appErr.Category != apperrors.CategoryInternal {
t.Fatalf("category = %q, want internal", appErr.Category)
}
if got := apperrors.ExitCode(err); got != 5 {
t.Fatalf("exit code = %d, want 5 (internal)", got)
}
if !strings.Contains(err.Error(), "resolve daemon dir") {
t.Fatalf("message = %q, want contains resolve daemon dir", err.Error())
}
if out.Len() != 0 {
t.Fatalf("error path must keep stdout empty, got %q", out.String())
}
}
// TestDevAppCreateMissingNameErrorPath 是队列 B134:create 缺 --name 报错、
// stdout 零字节。LeafSpec RequiredHint 由 corecmd 统一转成 typed validation,
// 与手写参数校验共享 rc=3。
func TestDevAppCreateMissingNameErrorPath(t *testing.T) {
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
"dev", "app", "create", "--yes")
if err == nil || !strings.Contains(err.Error(), "--name 为必填") {
t.Fatalf("error = %v, want --name 为必填", err)
}
var appErr *apperrors.Error
if !stderrors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
t.Fatalf("error = %T (%v), want typed validation", err, err)
}
if got := apperrors.ExitCode(err); got != 3 {
t.Fatalf("exit code = %d, want 3 (validation)", got)
}
if out.Len() != 0 {
t.Fatalf("error path must keep stdout empty, got %q", out.String())
}
}
// TestDevAppVersionPublishMissingParamsErrorPath 是队列 B135:version publish
// 参数缺失报错、stdout 零字节。ValidateRequired 按声明顺序报第一个缺失的
// 必填参数(先 unified-app-id 后 version-id);两者均为 typed validation rc=3。
func TestDevAppVersionPublishMissingParamsErrorPath(t *testing.T) {
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
"dev", "app", "version", "publish", "--yes")
if err == nil || !strings.Contains(err.Error(), "--unified-app-id 为必填") {
t.Fatalf("error = %v, want --unified-app-id 为必填", err)
}
if got := apperrors.ExitCode(err); got != 3 {
t.Fatalf("exit code = %d, want 3 (validation)", got)
}
if out.Len() != 0 {
t.Fatalf("error path must keep stdout empty, got %q", out.String())
}
out2, _, err := runDevDomainErrorCase(t, &captureRunner{},
"dev", "app", "version", "publish", "--yes", "--unified-app-id", "u-1")
if err == nil || !strings.Contains(err.Error(), "--version-id 为必填") {
t.Fatalf("error = %v, want --version-id 为必填", err)
}
if got := apperrors.ExitCode(err); got != 3 {
t.Fatalf("exit code = %d, want 3 (validation)", got)
}
if out2.Len() != 0 {
t.Fatalf("error path must keep stdout empty, got %q", out2.String())
}
}
// TestDevAppDeleteDisableEnableMissingUnifiedAppID 是队列 B136:
// delete/disable/enable 缺统一应用标识报错、stdout 零字节,均由统一 required
// preflight 返回 typed validation rc=3。
func TestDevAppDeleteDisableEnableMissingUnifiedAppID(t *testing.T) {
for _, leaf := range []string{"delete", "disable", "enable"} {
t.Run(leaf, func(t *testing.T) {
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
"dev", "app", leaf, "--yes")
if err == nil || !strings.Contains(err.Error(), "--unified-app-id 为必填") {
t.Fatalf("error = %v, want --unified-app-id 为必填", err)
}
var appErr *apperrors.Error
if !stderrors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
t.Fatalf("error = %T (%v), want typed validation", err, err)
}
if got := apperrors.ExitCode(err); got != 3 {
t.Fatalf("exit code = %d, want 3 (validation)", got)
}
if out.Len() != 0 {
t.Fatalf("error path must keep stdout empty, got %q", out.String())
}
})
}
}
// TestDevDomainErrorPathsKeepStdoutEmpty 是队列 B137 的总断言(AC-11):
// dev 域全部错误路径 stdout 严格零字节。dev 域失败走 apperrors 通道(生产
// root 错误处理器写 stderr),Phase F 不引入 failure 信封(轮 8 裁决⑪),
// 故 AC-11 在测试层的投影即「stdout 零字节」。各用例的结构化/消息断言见
// 上述分测试,本表只锁流纪律。
func TestDevDomainErrorPathsKeepStdoutEmpty(t *testing.T) {
daemonDirSetup := func(t *testing.T) {
t.Helper()
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
}
cases := []struct {
name string
args []string
setup func(t *testing.T)
}{
{"app get missing locator", []string{"dev", "app", "get"}, nil},
{"app update no field", []string{"dev", "app", "update", "--unified-app-id", "u-1", "--dry-run"}, nil},
{"app create missing name", []string{"dev", "app", "create", "--yes"}, nil},
{"app delete missing id", []string{"dev", "app", "delete", "--yes"}, nil},
{"app disable missing id", []string{"dev", "app", "disable", "--yes"}, nil},
{"app enable missing id", []string{"dev", "app", "enable", "--yes"}, nil},
{"version publish missing params", []string{"dev", "app", "version", "publish", "--yes"}, nil},
{"connect unknown channel", []string{"dev", "connect", "--channel", "bogus"}, nil},
{"connect status missing locator", []string{"dev", "connect", "status"}, nil},
{"connect restart missing record", []string{"dev", "connect", "restart", "--robot-client-id", "ghost"}, daemonDirSetup},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.setup != nil {
tc.setup(t)
}
out, _, err := runDevDomainErrorCase(t, &captureRunner{}, tc.args...)
if err == nil {
t.Fatalf("want error, got nil\nstdout:\n%s", out.String())
}
if out.Len() != 0 {
t.Fatalf("error path must keep stdout empty (AC-11), got %q", out.String())
}
})
}
}
@@ -0,0 +1,44 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
func TestDevFormerExclusionsHaveFinalContractAndUnifiedRollout(t *testing.T) {
root := newDevAppTestRoot(&captureRunner{})
for _, tc := range []struct {
path []string
rollout output.RolloutState
}{
{[]string{"dev", "connect", "list"}, output.RolloutLegacyOnly},
{[]string{"dev", "connect", "restart"}, output.RolloutUnifiedActive},
{[]string{"dev", "app", "version", "check-approval"}, output.RolloutUnifiedActive},
} {
cmd, _, err := root.Find(tc.path)
if err != nil || cmd == nil || !cmd.Runnable() {
t.Fatalf("%v is not a runnable command: cmd=%v err=%v", tc.path, cmd, err)
}
if _, ok := contractfinal.RuntimeContractFinal(cmd); !ok {
t.Fatalf("%s has no ContractFinal", cmd.CommandPath())
}
if got := output.CommandRollout(cmd); got != tc.rollout {
t.Fatalf("%s rollout=%s, want %s", cmd.CommandPath(), got, tc.rollout)
}
}
}
@@ -0,0 +1,100 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"strings"
"testing"
"github.com/spf13/cobra"
)
// TestDevLeafFormatFlagVisibility 是队列 B189 的 dev 域叶子 --format flag 可见性
// 断言。--format 是生产根命令的持久 flag(internal/app/flags.go bindPersistentFlags),
// 叶子自身不声明 local --format,而是经 cmd.InheritedFlags() 继承可见。本测试:
//
// 1. 在完整命令树下执行 dev app/dev connect 叶子,--format 必须被 resolveCommandFormat
// 识别(table 生效),证明其可见;
//
// 2. 叶子 --help 必须展示 --format 于 Global Flags 段(继承可见性的人读证据)。
func TestDevLeafFormatFlagVisibility(t *testing.T) {
leaves := []struct {
name string
args []string
helpPath []string
}{
{"dev-app-get", []string{"dev", "app", "get", "--unified-app-id", "u-1"}, []string{"dev", "app", "get"}},
{"dev-app-list", []string{"dev", "app", "list", "--name", "DemoApp"}, []string{"dev", "app", "list"}},
{"dev-version-list", []string{"dev", "app", "version", "list", "--unified-app-id", "u-1"}, []string{"dev", "app", "version", "list"}},
{"dev-event-list", []string{"dev", "app", "event", "list", "--unified-app-id", "u-1"}, []string{"dev", "app", "event", "list"}},
{"dev-permission-list", []string{"dev", "app", "permission", "list", "--unified-app-id", "u-1"}, []string{"dev", "app", "permission", "list"}},
{"dev-member-list", []string{"dev", "app", "member", "list", "--unified-app-id", "u-1"}, []string{"dev", "app", "member", "list"}},
}
for _, lc := range leaves {
t.Run(lc.name, func(t *testing.T) {
// --format table 必须生效:输出为表(含列头/业务值),且不泄漏信封外壳键。
args := append(append([]string{}, lc.args...), "--format", "table")
root := newDevAppTestRoot(devAppFamilyContentRunner(map[string]any{"items": []any{map[string]any{"id": "one"}}}))
out, errBuf, err := runRootBuffered(t, root, args...)
if err != nil {
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
}
s := out.String()
if strings.Contains(s, `"outcome"`) || strings.Contains(s, `"ok":`) {
t.Fatalf("--format table not effective (envelope shell leaked):\n%s", s)
}
if strings.TrimSpace(s) == "" {
t.Fatalf("--format table produced empty stdout (format not visible?):\n%s", s)
}
// --help 必须展示 --format 于 Global Flags 段。
helpArgs := append(append([]string{}, lc.helpPath...), "--help")
helpRoot := newDevAppTestRoot(&captureRunner{})
helpOut, _, herr := runRootBuffered(t, helpRoot, helpArgs...)
if herr != nil {
t.Fatalf("--help error = %v", herr)
}
if !strings.Contains(helpOut.String(), "--format") {
t.Fatalf("leaf --help must expose --format (global inherited flag):\n%s", helpOut.String())
}
})
}
}
// TestDevLeafFormatFlagHelpShowsGlobalSection 是队列 B189 的补充观察断言:dev 叶子
// 的 --help 中 --format 出现在 "Global Flags" 段而非本地 Flags 段(flag 可见性来源
// 为根命令持久继承,而非叶子自声明)。这是 B189 结论的形式化——若某叶子把
// --format 放回本地段,本测试会因 --format 出现在非 Global 段而暴露。
func TestDevLeafFormatFlagHelpShowsGlobalSection(t *testing.T) {
root := newDevAppTestRoot(&captureRunner{})
out, _, err := runRootBuffered(t, root, "dev", "app", "get", "--help")
if err != nil {
t.Fatalf("--help error = %v", err)
}
help := out.String()
globalIdx := strings.Index(help, "Global Flags")
if globalIdx < 0 {
t.Fatalf("leaf help missing 'Global Flags' section:\n%s", help)
}
formatIdx := strings.LastIndex(help, "--format")
if formatIdx < 0 {
t.Fatalf("leaf help missing --format in Global Flags:\n%s", help)
}
if formatIdx < globalIdx {
t.Fatalf("--format found before 'Global Flags' section (should be inherited global):\n%s", help)
}
}
var _ = cobra.Command{}
@@ -0,0 +1,150 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"encoding/json"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
// devAppFormatMatrixCases 是 B117 分发表驱动用例集:读叶子(成功路径)与
// 写叶子(--dry-run pending 路径)各取一个代表,覆盖 json/table/pretty/
// csv/ndjson/raw 六种 format。契约规范 §5.2:json = 完整信封(唯一 JSON
// 契约);table/pretty/csv/ndjson/raw = 仅 data 通道(不输出信封外壳)。
func TestDevAppFormatMatrixDispatching(t *testing.T) {
readArgs := []string{"dev", "app", "get", "--unified-app-id", "u-1"}
writeArgs := []string{"dev", "app", "update", "--unified-app-id", "u-1", "--name", "NewName", "--dry-run"}
readContent := map[string]any{
"unifiedAppId": "u-1",
"name": "DemoApp",
"appStatus": "ENABLED",
}
writeContent := map[string]any{"unifiedAppId": "u-1", "name": "NewName"}
// 每个 format 的断言:'envelope' 表示 stdout 必须是完整信封(默认 json、
// 未知降级);'data' 表示必须只渲染 data(无信封外壳键)。
cases := []struct {
name string
format string
wantKind string // "envelope" | "data"
}{
{"json", "json", "envelope"},
{"table", "table", "data"},
{"pretty", "pretty", "data"},
{"csv", "csv", "data"},
{"ndjson", "ndjson", "data"},
{"raw", "raw", "data"},
}
for _, mode := range []struct {
name string
args []string
content map[string]any
wantMarker string
}{
{"read", readArgs, readContent, "DemoApp"},
{"write-dryrun", writeArgs, writeContent, "NewName"},
} {
for _, tc := range cases {
t.Run(mode.name+"/"+tc.name, func(t *testing.T) {
args := append([]string{}, mode.args...)
args = append(args, "--format", tc.format)
out, errBuf, err := runDevAppFamily(t, devAppFamilyContentRunner(mode.content), args...)
if err != nil {
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
}
s := out.String()
if !strings.Contains(s, mode.wantMarker) {
t.Fatalf("stdout missing business value %q:\n%s", mode.wantMarker, s)
}
switch tc.wantKind {
case "envelope":
var env map[string]any
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("-f %s stdout is not a JSON envelope: %v\n%s", tc.format, err, s)
}
if env["ok"] != true {
t.Fatalf("-f %s envelope ok = %#v, want true", tc.format, env["ok"])
}
case "data":
// 非 json 只渲染 data:不得泄漏信封外壳键。
for _, banned := range []string{`"ok"`, `"outcome"`, `"dry_run"`} {
if strings.Contains(s, banned) {
t.Fatalf("-f %s leaked envelope wrapper key %s:\n%s", tc.format, banned, s)
}
}
}
// 成功/dry-run 数据路径 stderr 应无噪声(除 dry-run 外的
// 无 format warning 场景)。
if strings.Contains(errBuf.String(), "[WARN]") {
t.Fatalf("-f %s unexpected warning on stderr: %q", tc.format, errBuf.String())
}
})
}
}
}
// TestDevAppFormatMatrixUnknownDegradesToJSON 是 B117 的未知值降级断言:
// 未知 --format 走 AC-09 降级为完整 JSON 信封 + stderr 单行 warning,数据不丢。
// 与轮12 全链路降级回归(TestUnknownFormatFullChainDegradesOverDevAppCommand)
// 同口径,此处作为分发表驱动矩阵的一行固化。
func TestDevAppFormatMatrixUnknownDegradesToJSON(t *testing.T) {
out, errBuf, err := runDevAppFamily(t,
devAppFamilyContentRunner(map[string]any{"unifiedAppId": "u-1", "name": "DemoApp"}),
"dev", "app", "get", "--unified-app-id", "u-1", "--format", "bogus")
if err != nil {
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
}
var env regressionEnvelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("unknown format must degrade to JSON envelope: %v\n%s", err, out.String())
}
if env.OK == nil || !*env.OK || env.Outcome != "success" {
t.Fatalf("degraded envelope malformed: %s", out.String())
}
if env.Data["name"] != "DemoApp" {
t.Fatalf("business payload lost in degradation: %s", out.String())
}
warning := errBuf.String()
if !strings.Contains(warning, "[WARN]") || !strings.Contains(warning, "bogus") {
t.Fatalf("stderr warning missing: %q", warning)
}
}
// TestDevAppFormatMatrixRawPassThrough 是 B117 raw 语义断言:raw 直接透传
// data 载荷(不输出信封外壳)。dev app 成功载荷为 map,raw 即其紧凑 JSON。
func TestDevAppFormatMatrixRawPassThrough(t *testing.T) {
out, errBuf, err := runDevAppFamily(t,
devAppFamilyContentRunner(map[string]any{"unifiedAppId": "u-1", "name": "DemoApp"}),
"dev", "app", "get", "--unified-app-id", "u-1", "--format", "raw")
if err != nil {
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
}
s := out.String()
if !strings.Contains(s, "DemoApp") {
t.Fatalf("raw output missing business value:\n%s", s)
}
for _, banned := range []string{`"ok"`, `"outcome"`} {
if strings.Contains(s, banned) {
t.Fatalf("raw leaked envelope wrapper key %s:\n%s", banned, s)
}
}
_ = errBuf
}
var _ output.Format = output.FormatJSON
@@ -0,0 +1,87 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"encoding/json"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
)
// TestDevAppEnvelopeJSONMatchesJSONUtilEscaping 是队列 B121 的信封 JSON 与
// jsonutil 转义一致性断言:信封 JSON 经 jsonutil.MarshalIndent(HTML 转义
// 关闭)序列化,& < > 与 URL 查询分隔符原样保留,不出现 \u003c/\u0026 等
// HTML 转义序列。业务载荷含 URL 时不应被转义破坏。
func TestDevAppEnvelopeJSONMatchesJSONUtilEscaping(t *testing.T) {
content := map[string]any{
"unifiedAppId": "u-1",
"homepageUrl": "https://example.com/redirect?dest=a&from=b&raw=<payload>",
"name": "Demo<App>&Co",
}
out, errBuf, err := runDevAppFamily(t, devAppFamilyContentRunner(content),
"dev", "app", "get", "--unified-app-id", "u-1")
if err != nil {
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
}
s := out.String()
// ① 载荷 URL/特殊字符原样保留(HTML 转义关闭)。
for _, want := range []string{"https://example.com/redirect?dest=a&from=b&raw=<payload>", "Demo<App>&Co"} {
if !strings.Contains(s, want) {
t.Fatalf("envelope JSON must preserve raw %q (HTML escaping disabled):\n%s", want, s)
}
}
// ② 不得出现 HTML 转义序列。
for _, banned := range []string{"\\u003c", "\\u003e", "\\u0026", "\\u003d", "\\u002f"} {
if strings.Contains(s, banned) {
t.Fatalf("envelope JSON must not HTML-escape to %q:\n%s", banned, s)
}
}
// ③ 与 jsonutil.MarshalIndent 对同一数据载荷的序列化逐字一致(转义规则同源)。
env := map[string]any{
"ok": true,
"outcome": "success",
"data": content,
}
wantJSON, err := jsonutil.MarshalIndent(env, "", " ")
if err != nil {
t.Fatalf("jsonutil.MarshalIndent: %v", err)
}
// 信封 Data 是 content 的强类型 map,序列化键序稳定:比对 data 片段即可。
var gotData struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &gotData); err != nil {
t.Fatalf("stdout not a JSON envelope: %v\n%s", err, out.String())
}
wantDataStruct := struct {
Data map[string]any `json:"data"`
}{Data: content}
wantDataJSON, err := jsonutil.MarshalIndent(wantDataStruct, "", " ")
if err != nil {
t.Fatalf("jsonutil.MarshalIndent(data): %v", err)
}
gotDataJSON, err := jsonutil.MarshalIndent(gotData, "", " ")
if err != nil {
t.Fatalf("jsonutil.MarshalIndent(got): %v", err)
}
if string(gotDataJSON) != string(wantDataJSON) {
t.Fatalf("envelope data serialization diverges from jsonutil:\nGOT:\n%s\nWANT:\n%s",
gotDataJSON, wantDataJSON)
}
_ = wantJSON
}

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