Compare commits

...
Author SHA1 Message Date
chichuan a637a44b7a docs(release): 恢复 beta.6 main admission(风险等级:低)
明确 beta.6 五个 PR 审计范围,并由真实用户合入以触发 main CHANGELOG fast-path CI。
2026-07-29 16:52:33 +08:00
github-actions[bot] 579eed81d9 Merge pull request #818 from DingTalk-Real-AI/codex/changelog-v1.0.55-beta.6
docs(release): prepare v1.0.55-beta.6 changelog
2026-07-29 16:38:54 +08:00
chichuan c68d9facb2 docs(release): 补充 CHANGELOG beta.6 五项合入说明(风险等级:低) 2026-07-29 16:33:48 +08:00
github-actions[bot] 1f9138e99a Merge pull request #621 from typefield/agent/sync-wukong-multi-skill
feat(skills): add  multi-skill framework to DWS
2026-07-29 16:24:53 +08:00
玉澜 c3fd814630 Merge remote-tracking branch 'origin/main' into pr621-wukong-sync
# Conflicts:
#	CHANGELOG.md
2026-07-29 16:08:16 +08:00
github-actions[bot] 5922a0717a Merge pull request #816 from wxianfeng/feature/aone82250541-agent-product
feat: support configurable Agent Product identity
2026-07-29 16:04:24 +08:00
玉澜 c5bc1fdad4 Merge remote-tracking branch 'typefield/agent/sync-wukong-multi-skill' into pr621-wukong-sync
# Conflicts:
#	CHANGELOG.md
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
#	internal/cli/schema_parameter_bindings.json
2026-07-29 15:51:04 +08:00
玉澜 9567cfd3d8 fix(review): align wukong port with upstream behavior and PR #621 review findings
Must-fix: drive permission apply now gates on confirmDangerousAction and
declares confirmation=user_required, matching its help-text promise.

Wukong parity restored: formula-verify --exit-on-error (payload-parsing
exit path) and --targets conflict error, sheet info --include, chat
location/profile message types, search-advanced wukong flag aliases,
and a dedicated drive download-version leaf replacing the removed
polymorphic download --version.

Consistency fixes: transfer-owner --node/--workspace XOR and JSON-aware
dry-run after --yes validation; drive list --versions rejects
--depth/--pattern instead of misleading depth errors; depth BFS resumes
rate-limited folders from the failed page cursor to avoid duplicates;
doc style cover upload honors cmd.Context() and a 20 MiB size cap; chat
user-settings set validates per-item openConversationId and is
risk=medium.

Hardened the skill static audit to scan fenced code blocks and reject
unknown subcommands on group commands, fixing the stale aitable/drive
doc examples it exposed. Added CHANGELOG entry and coverage tests for
all changed statements plus previously untested ported commands.
2026-07-29 15:34:59 +08:00
chichuan 1180510f40 merge(agent-product): 同步 main 并解决 CHANGELOG 冲突(风险等级:高)
保留 #816 的 Agent Product 身份说明与 main 中已合入的 Shortcut 修复条目,并完成全仓测试、构建及 Schema 生成漂移校验。
2026-07-29 15:19:28 +08:00
chichuan 2456660780 test(chat): 补齐文字表情跨平台覆盖(风险:低)
让 update-text-emotion 映射与缺参测试进入 Darwin/Windows coverage 矩阵,并移除已由 Cobra 必填门禁覆盖的不可达重复校验。
2026-07-29 14:36:41 +08:00
chichuan c3dbe866c4 feat(chat): 补齐文字表情原地更新契约(风险:低)
基于 PR #621 现有 update-text-emotion 实现,补齐七参数 RPC 映射、Cobra/Schema 必填约束、mono Skill、CHANGELOG 与别名/缺参回归测试。
2026-07-29 14:17:03 +08:00
wxianfeng 81f130c483 fix: address agent product review feedback 2026-07-29 13:56:25 +08:00
玉澜 6b99685594 fix(schema): bump runtime-surface completeness source_tools to 839
The 26 newly registered commands raised the registry count to 839, but
runtime-surface-completeness.json still declared source_tools=813, so
check-schema-catalog.sh failed the Policy job ("runtime-surface
completeness source must remain unreviewed and interface-free"). The 26
tools are all reviewed in metadata/selection sources, so the unreviewed
71-tool list is unchanged; regenerate dependent schema artifacts.
2026-07-29 13:51:25 +08:00
玉澜 9d59550890 test(helpers): cover new drive/doc-style/sheet/chat commands to 100% changed-code coverage
The CI platform coverage gate enforces 100% coverage of changed
statements via tests named TestCrossPlatformCoverage*/TestAllShortcuts.
Add unit tests for drive list --depth BFS (pagination, rate-limit retry,
dedup, truncation, SIGINT, anomalies), drive list --versions/transfer-
owner/cover/revert paths, doc style cover upload flow, sheet
formula-verify target parsing, and chat group user-settings validation.
Also drop an unreachable resourceID guard in uploadDocStyleImage.
2026-07-29 13:29:22 +08:00
玉澜 83f13d8e11 Merge remote-tracking branch 'origin/main' into pr621-wukong-sync
# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
2026-07-29 12:25:45 +08:00
wxianfeng 86bce64cc8 test: cover agent product header branches 2026-07-29 12:06:43 +08:00
玉澜 68e1a78810 feat(cli): port drive list --depth and doc style, register all new commands in Schema
- Port drive list --depth N BFS recursive listing (pan + workspace routes,
  rate-limit requeue, SIGINT partial emit, --pattern/--quiet)
- Port doc style cover set/clear, background set/clear, get with local
  image validation and attachment-upload subflow
- Register all 26 newly ported commands in schema_command_registry with
  reviewed metadata/selection hints instead of exclusions (813->839 tools)
- Review fixes: drive list --node usage text no longer implies required
  in agent schema; remove broken formula-verify --exit-on-error; error on
  --range without --sheet-id; portable stdin read; drop local --yes
  shadowing root -y on drive revert/transfer-owner; use
  confirmDangerousAction for non-delete confirms; explicit
  recursiveChange=false now transmitted; sheet version revert and
  comment delete moved into sheet confirmationGuards registry
2026-07-29 11:57:57 +08:00
玉澜 e63a4bdf47 feat(cli): add chat group get-mute-config command from wukong develop 2026-07-29 11:18:49 +08:00
github-actions[bot] 6ab01a365e Merge pull request #815 from DingTalk-Real-AI/codex/im-shortcut-optimization
feat(chat): harden and publish IM shortcuts
2026-07-29 11:05:56 +08:00
玉澜 d855edaad2 feat(cli): add chat message update-text-emotion command 2026-07-29 11:03:04 +08:00
玉澜 75fce5c4ff Merge remote-tracking branch 'origin/main' into pr621-wukong-sync
# Conflicts:
#	internal/cli/schema_catalog.json
2026-07-29 10:53:12 +08:00
玉澜 d28a50c0f4 fix(cli): resolve schema parameter mapping for drive download
Remove --version flag from drive download (polymorphic tool dispatch
incompatible with schema validation). Regenerate schema catalog and
add new commands to schema_command_exclusions.json.
2026-07-29 10:12:15 +08:00
Dennis ecbd2e3009 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-optimization
# Conflicts:
#	internal/cli/schema_catalog.json
2026-07-29 09:57:46 +08:00
Dennis 33df6ee794 test(chat): close IM shortcut coverage gaps 2026-07-29 09:46:00 +08:00
github-actions[bot] 18e1c7870e Merge pull request #676 from typefield/feat/command-surface-naming
feat(helpers): declarative LeafSpec command framework + devapp migration
2026-07-29 09:43:34 +08:00
玉澜 bbecd2f3a6 style: gofmt chat.go 2026-07-29 09:23:27 +08:00
玉澜 a705c9de0b feat(cli): implement wukong-internal commands in open-source CLI
Port 19 command leaves from wukong internal CLI:
- drive star add/remove/list (文档收藏)
- drive cover (节点封面)
- drive revert (文件版本回滚)
- drive list --versions / download --version (文件历史版本)
- drive permission transfer-owner/apply-info/apply
- sheet version save/list/revert
- sheet formula-verify
- sheet comment list/create/reply/update/delete
- chat group user-settings query/set

Restore corresponding skill docs and register commands in schema
exclusions pending Schema review.
2026-07-29 01:06:36 +08:00
玉澜 1ff4941082 Merge remote-tracking branch 'upstream/main' into feat/command-surface-naming 2026-07-29 00:53:26 +08:00
玉澜 f5d57c2e07 Merge remote-tracking branch 'origin/main' into pr621-wukong-sync 2026-07-29 00:32:22 +08:00
Dennis f3231ed2a8 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-optimization
# Conflicts:
#	internal/shortcut/chat/compatibility_coverage_test.go
#	internal/shortcut/smart/compatibility_coverage_test.go
2026-07-29 00:29:53 +08:00
玉澜 7762abc1ae refactor(helpers): drop unused LeafInt64 kind (CR C1)
No production LeafSpec uses LeafInt64; devapp only needs LeafInt
(non-zero-only putInt semantics). The default MCP dispatch and Server
routing stay — they are the framework's documented main path for
future MCP-direct products.
2026-07-29 00:29:21 +08:00
Dennis 8d1b76caa7 feat(chat): align and harden IM shortcuts 2026-07-29 00:21:26 +08:00
玉澜 9d0995a61d test(helpers): cover parse-error path in required validation 2026-07-28 23:02:06 +08:00
玉澜 967cf26d44 fix(skills): remove commands absent from open-source CLI
Remove references to wukong-internal-only commands that fail CI
Interface Integrity: drive permission transfer-owner/apply/apply-info,
drive star/cover/revert/list --versions, sheet comment/formula-verify/
version, chat group user-settings. Delete sheet-comment.md and
sheet-version.md entirely.
2026-07-28 22:52:46 +08:00
玉澜 571eb20457 refactor: align required/args semantics, trim-aware fallback, helper dedupe
Post-review cleanup round:
- leaf.go: required validation now matches leafArgs inclusion rules
  (LeafInt explicit 0 / LeafInt64 <= 0 count as missing) via
  leafHasEffectiveValue; fallback-chain candidates are judged after
  TrimSpace when Trim is set so pure-whitespace values fall through.
- command_meta.go: drop catalogStringVal/catalogStringSliceVal in favor
  of existing schemaString/schemaStringSlice.
- fetch_mcp_metadata: cross-owned canonicals skip name-coincidence
  direct merges; the reviewed cross-server identity is the sole source.
2026-07-28 22:52:33 +08:00
github-actions[bot] 7937d09eed Merge pull request #757 from DingTalk-Real-AI/fix/shortcut-audit-batch
fix(shortcut): 修复按姓名解析漏掉外部联系人 + resource-url 补 --msg-id 别名
2026-07-28 22:32:59 +08:00
玉澜 bc39d24559 fix(fetch-mcp-metadata): refresh cross-server tools via reviewed interface_refs
Live matching only recognized srv.ID+"."+name == registry canonical, so
the 101 canonicals whose reviewed interface_ref routes to a differently
named server/tool were silently skipped and stayed frozen at the
previous snapshot (or degraded to stubs). Build a reverse index from the
previous snapshot's reviewed interface_refs (live key → canonicals) and
fan the live descriptor out to every owning canonical, preserving the
reviewed ref through the existing merge semantics.
2026-07-28 22:04:32 +08:00
玉澜 d31cae2b0c Revert "docs(skills): add create→transfer-owner bridge for group owner scenario"
This reverts commit 4e71f56f97.
2026-07-28 22:04:21 +08:00
wxianfeng e998e2609d feat: support agent product identity to #82250541 2026-07-28 21:46:20 +08:00
玉澜 4e71f56f97 docs(skills): add create→transfer-owner bridge for group owner scenario
group create does not support --owner; agents need an explicit pointer
to transfer-owner when users ask to specify a group owner at creation.
2026-07-28 21:44:59 +08:00
玉澜 3717053d24 chore(helpers): drop dead devapp flag-registration helpers
addDevAppVersionLocatorFlags and registerDevAppMemberMutationFlags lost
their last callers when the dev app command surface was reworked; the
uncovered dead code regressed overall coverage below the merge base.
2026-07-28 21:33:26 +08:00
玉澜 2a3df50d0f refactor(cli): deterministic alias collision resolution and helper cleanup
alias-vs-alias collisions in the command meta lookup now resolve to the
owner with the lexicographically smallest primary path instead of map
iteration order. Move catalogStringVal next to its sibling helpers in
command_meta.go and drop the redundant captureBaseHelpFunc alias in the
calendar help wrapper. Unify the Safety help annotation to English
"(requires --yes)".
2026-07-28 21:13:49 +08:00
玉澜 03b3cf68e4 feat(coverage-gate): log files exempted for having no executable statements
Silently dropping non-executable changed files made the exemption
invisible in CI logs; each exempted path is now reported to stderr in
sorted order.
2026-07-28 21:13:40 +08:00
玉澜 bbdf843ef3 fix(fetch-mcp-metadata): count registry stubs as unmatched in coverage
matched_tools claimed every surface tool matched even when entries were
registry stubs with no live MCP metadata, and unmatched_tools was
hardcoded to 0. Coverage now excludes stubs from matched_tools, reports
them as unmatched, and a registry JSON parse failure warns instead of
silently producing a stub-only snapshot. The schema catalog policy
invariant is relaxed to match the honest accounting.
2026-07-28 21:13:40 +08:00
玉澜 eb2658ca68 fix(helpers): honor alias/env/default fallback for integer leaf flags
The leaf fallback chain read only string flags, so LeafInt/LeafInt64
flags could never satisfy Required via alias or env, alias values for
integer flags were silently dropped, and a registered Default shadowed
alias/env values. Resolution order is now explicit flag > alias > env >
Default > ArgDefault, aliases register with the primary flag's Kind, and
unparsable integer env values fail loudly.
2026-07-28 21:13:29 +08:00
玉澜 69b8df3e40 fix(skills): reconcile wukong sync with latest main CLI surface
Restore capabilities now supported on main (doc read --scope/--tags,
drive upload --node overwrite, chat category, dingtalk-markdown routing),
remove commands still absent from the open-source CLI (calendar event
instances, sheet info --include, chat group create --owner), remap
folded services (attendance/ding/oa/report/sheet) to dingtalk-misc in
the shortcut generator, and regenerate shortcut sections and schema
metadata.
2026-07-28 20:56:37 +08:00
Dennis a5ac09218b fix(chat): close IM shortcut validation gaps 2026-07-28 20:55:25 +08:00
玉澜 8d988bc350 Merge remote-tracking branch 'origin/main' into pr621-wukong-sync
# Conflicts:
#	skills/multi/dingtalk-aitable/SKILL.md
#	skills/multi/dingtalk-attendance/SKILL.md
#	skills/multi/dingtalk-calendar/SKILL.md
#	skills/multi/dingtalk-chat/SKILL.md
#	skills/multi/dingtalk-chat/references/chat.md
#	skills/multi/dingtalk-contact/SKILL.md
#	skills/multi/dingtalk-contact/references/contact.md
#	skills/multi/dingtalk-ding/SKILL.md
#	skills/multi/dingtalk-doc/SKILL.md
#	skills/multi/dingtalk-doc/references/doc.md
#	skills/multi/dingtalk-doc/references/doc/doc-comment.md
#	skills/multi/dingtalk-doc/references/doc/doc-read.md
#	skills/multi/dingtalk-drive/SKILL.md
#	skills/multi/dingtalk-drive/references/drive.md
#	skills/multi/dingtalk-mail/SKILL.md
#	skills/multi/dingtalk-minutes/SKILL.md
#	skills/multi/dingtalk-oa/SKILL.md
#	skills/multi/dingtalk-report/SKILL.md
#	skills/multi/dingtalk-sheet/SKILL.md
#	skills/multi/dingtalk-todo/SKILL.md
#	skills/multi/dingtalk-todo/references/todo.md
#	skills/multi/dingtalk-wiki/SKILL.md
#	skills/multi/dws-shared/SKILL.md
2026-07-28 20:25:16 +08:00
玉澜 dc20ddecf6 feat(skills): sync wukong 13-sub-skill multi layout with open-source cleanup
Replace skills/multi with wukong's consolidated structure (long-tail
products folded into dingtalk-misc), keeping GitHub-only skills
(dingtalk-dev/event/pat/profile/skill). Prune MCP-only product refs and
align all documented commands/flags with the open-source Cobra tree:
remove markdown/*, drive task get, drive version flags, doc read
--scope, --async modes, retired conference/chat-file-upload mentions.
2026-07-28 20:20:12 +08:00
DennisandClaude Opus 4.8 d41214988a fix(shortcut): keep external contacts in name resolution; alias resource-url msg-id
Two independent shortcut correctness fixes surfaced by the audit:

- Name→ID resolution (chat +dm / +broadcast / … via the shared resolver) dropped
  every search_contact_by_key_word row with an empty userId. External /
  cross-org contacts arrive with only an openDingTalkId, so they were silently
  discarded — making resolution report a real person as missing, or collapse to
  the wrong single match when an in-org namesake existed. Keep any row with at
  least one usable identity (userId or openDingTalkId) and fall the display name
  back through nick/showName/flowerName/staffName/userName.

- chat +messages-resource-url required --message-id with no alias, so an agent
  copying the message list's openMessageId/msgId output field hit "unknown
  flag". Accept --msg-id / --open-message-id as aliases (declared via an
  at-least-one constraint since a shortcut's Required check only sees the
  primary flag name), mirroring the earlier chat message download-media fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 20:11:26 +08:00
Dennis bfb812bfa0 feat(chat): publish and harden all IM shortcuts 2026-07-28 18:59:04 +08:00
玉澜 a09790fc3f Merge remote-tracking branch 'origin/main' into pr621-wukong-sync
# Conflicts:
#	test/skill_static/skill_static_test.go
2026-07-28 18:10:50 +08:00
玉澜 4b0f71eedc test: close changed-code coverage gaps to satisfy the coverage gate
- fetch_mcp_metadata: extract run()/resolveToken()/writeMetadata with
  injectable deps (keychain, servers, lister, registry, exit); full-path
  tests reach 100% file coverage.
- internal/cli: drop dead initSafetyByCLIPath (superseded by ResolveMeta),
  split buildMetaByCLIPath / assembleSchemaCatalogSnapshot /
  assembleCommandRegistryFrom / mergedCommandRegistryJSON so shard and
  malformed-snapshot failure modes are testable; cover catalog structure
  violation formatting (sort/truncate) and RenderSafetyAnnotation.
- generators: cover registry shard merge and catalog shard write failure
  modes.
- helpers/cmdutil: cover LeafSpec default/server dispatch, transform error
  propagation, default env hint, devapp member remove validate chain, and
  the required-flags error helpers.

Local gate: overall 90.17% vs merge-base 89.96%, changed-code 100%
(861 statements); make policy and go test ./... green.
2026-07-28 18:05:20 +08:00
玉澜 5c30d01522 fix(coverage-gate): exempt files without executable statements
A changed production Go file with no function bodies (pragma carriers such
as internal/cli/gen.go, doc-only files) can never appear in a coverage
profile, so the missing-profile check failed every PR touching one. Parse
changed files and exempt those without executable statements; unreadable
or unparsable files stay conservative.
2026-07-28 18:05:19 +08:00
玉澜 c94190f90e fix: honor alias/env fallback for plain required LeafSpec flags
Plain Required now validates the effective value (primary flag -> aliases
-> env) instead of only the primary flag, matching the declared fallback
semantics; whitespace-only values under Trim count as missing. Extracted
cmdutil.MissingRequiredFlagsError to keep the unified error format.
2026-07-28 16:48:42 +08:00
玉澜 6763ddd154 fix: resolve command metadata via compat aliases
ResolveMeta copies Catalog aliases into CommandIdentity and registers each
alias path against the same metadata (primary cli_path wins on collision),
so compat paths like 'report list' resolve instead of returning ok=false.
2026-07-28 16:48:42 +08:00
玉澜 235cad4cc7 fix: report honest MCP snapshot coverage
snapshot_services now counts only services whose tools/list succeeded and
missing_services names the failures, so a partially failed refresh can no
longer write a snapshot that claims full coverage.
2026-07-28 16:48:42 +08:00
玉澜 96d0d430e6 Merge upstream main into feat/command-surface-naming 2026-07-28 16:12:38 +08:00
github-actions[bot] 5783c4e82a chore: update beta formula for v1.0.55-beta.5 [skip ci] 2026-07-28 07:10:13 +00:00
chichuanandchichuan baafd6fe7d docs(CHANGELOG): 补充 v1.0.55-beta.5 精确发布说明(风险等级:文档级) (#812)
Co-authored-by: chichuan <haofeng.hf@alibaba-inc.com>
2026-07-28 15:02:24 +08:00
github-actions[bot] 23c3b74979 Merge pull request #803 from DingTalk-Real-AI/codex/fix-contract-defects
fix: harden dws contract edge cases
2026-07-28 14:46:06 +08:00
Dennis 258e7ed872 fix: align doc rename schema contract 2026-07-28 14:30:29 +08:00
Dennis 69d813afef fix: address contract review feedback 2026-07-28 12:09:57 +08:00
Dennis 00305d941d fix: preserve document info schema compatibility 2026-07-28 11:37:32 +08:00
Dennis ec7fdb0f0d fix: harden dws contract edge cases 2026-07-28 11:36:44 +08:00
github-actions[bot] a8e83e5e7e Merge pull request #804 from wxianfeng/feature/aone84760010-qwenwork-agent-host
feat: add agent host observation metadata
2026-07-28 03:02:43 +00:00
修雨 488d90b73c Merge branch 'main' into feature/aone84760010-qwenwork-agent-host 2026-07-28 10:51:27 +08:00
修雨 2c10be2a1a feat(schema): publish 210 built-in shortcuts (#802)
Publishes all 210 public built-in shortcuts as reviewed Agent-visible
leaf tools across 16 product groups, with stable canonical identities,
executable +shortcut CLI paths, parameter and cross-parameter
constraints, selection guidance, interface metadata, and runtime-aligned
safety/confirmation semantics. Catalog grows from 603 to 813 tools.
2026-07-28 00:11:29 +08:00
wxianfeng 3985c4c98f feat: add agent host observation metadata (Aone 84760010) 2026-07-27 22:09:58 +08:00
wxianfeng 196bf929c1 Merge remote-tracking branch 'upstream/main' 2026-07-27 20:50:49 +08:00
github-actions[bot] 2dfc39f0d3 Merge pull request #790 from wxianfeng/feature/dws-event-im-phase3
feat(event): add multi-event and group lifecycle subscriptions
2026-07-27 10:00:25 +00:00
wxianfeng 97a16c43b4 fix(event): harden targeted consumer stop 2026-07-27 17:50:31 +08:00
wxianfeng afe7d860ff Merge remote-tracking branch 'upstream/main' 2026-07-27 15:57:39 +08:00
wxianfeng 63b3e72fad test(event): close coverage gate gaps 2026-07-27 15:35:49 +08:00
wxianfeng 984529b4cb test(event): cover multi-event edge paths 2026-07-27 14:59:00 +08:00
wxianfeng 298ea5b341 Merge remote-tracking branch 'upstream/main' into feature/dws-event-im-phase3
# Conflicts:
#	CHANGELOG.md
2026-07-27 13:47:23 +08:00
github-actions[bot] 3e4886fc71 chore: update beta formula for v1.0.55-beta.4 [skip ci] 2026-07-27 03:32:08 +00:00
修雨 72cb8f188e ci: trigger code admission on main after bot merges 2026-07-27 11:16:24 +08:00
github-actions[bot] 2f8614aa1f Merge pull request #798 from DingTalk-Real-AI/changelog-v1.0.55-beta.4
chore(release): prepare v1.0.55-beta.4
2026-07-27 10:58:01 +08:00
修雨 0e60d09980 chore(release): prepare v1.0.55-beta.4 2026-07-27 10:26:32 +08:00
github-actions[bot] 4d182dea45 Merge pull request #795 from DingTalk-Real-AI/codex/fix-chat-bots-projection
fix(shortcut): prevent projection data loss
2026-07-27 10:18:14 +08:00
修雨 f56d3263e8 chore: extend changelog entry and align npm propagation test with workflow
- CHANGELOG [Unreleased] entry now covers all three projection fixes
- npm dist-tag propagation test expects 60 attempts, matching release.yml
2026-07-26 21:49:12 +08:00
Dennis 22c94900d7 docs(changelog): note shortcut projection fix 2026-07-26 16:58:10 +08:00
Dennis 0871c5d88c fix(shortcut): preserve bot search and mail thread fields 2026-07-26 16:19:21 +08:00
Dennis 15a15a1c12 fix(shortcut): preserve chat bots projection 2026-07-26 15:48:21 +08:00
修雨 5c01ae845f fix: move event changelog entry to [Unreleased] + update npm propagation test
- Add missing ## [Unreleased] heading required by Policy CI check
- Update npm test assertion (12 → 60) to match extended propagation wait
2026-07-25 09:44:39 +08:00
修雨 36b58d08b0 Merge branch 'main' into feature/dws-event-im-phase3 2026-07-25 09:33:53 +08:00
修雨 0cc049eaa1 fix(release): handle Gitee API 200 null response for missing releases
Gitee API returns HTTP 200 with null body when a release tag doesn't
exist, unlike GitHub which returns 404. Treat empty release_id as
"no release exists" instead of erroring out.
2026-07-24 18:58:30 +08:00
修雨 dd2d91ae7e feat(ci): add release asset sync to Gitee mirror workflow
Add `sync_release_version` input to mirror-to-gitee.yml for ad-hoc
release asset synchronization that bypasses the release.yml verify
gate when tag metadata cannot be updated.
2026-07-24 18:54:47 +08:00
修雨 98309fa239 fix(ci): increase npm CDN propagation timeout with incremental backoff
npm registry behind CDN can take 1-5 minutes for dist-tag to propagate
to edge nodes. Extend max attempts from 12 to 60 and use incremental
backoff: 5s for first 12 attempts, then 10s.
2026-07-24 18:48:08 +08:00
修雨 b4e92f4e65 chore(release): prepare v1.0.55-beta.3 2026-07-24 18:48:03 +08:00
修雨 b34bbc9aa0 ci: enforce beta and stable release roles (#791) 2026-07-24 18:15:55 +08:00
wxianfeng 3749c6b793 docs: update changelog for personal events 2026-07-24 17:59:19 +08:00
github-actions[bot] 40444a6f78 chore: update beta formula for v1.0.55-beta.3 [skip ci] 2026-07-24 09:35:06 +00:00
修雨 97248bf7c2 chore(release): prepare v1.0.55-beta.3 2026-07-24 16:41:45 +08:00
修雨 fd6b3be046 ci: tier PR quality gates and automate review routing (#788)
Tier PR validation by risk, distribute peer review automatically, and enable a streamlined quality-preserving merge path.
2026-07-24 16:37:03 +08:00
wxianfeng e2390c3385 Merge remote-tracking branch 'upstream/main' into feature/dws-event-im-phase3
# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog.json
#	skills/mono/SKILL.md
2026-07-24 16:31:29 +08:00
修雨 835c9229fd ci: tier PR quality gates and automate review routing 2026-07-24 16:24:59 +08:00
修雨 ea7d8cc666 Merge pull request #783 from DingTalk-Real-AI/fix/shortcut-projection-data-loss
fix(shortcut): fix projection-data-loss silent-empty returns
2026-07-24 15:50:25 +08:00
wxianfeng 125bc88d52 fix(event): switch personal defaults to production 2026-07-24 15:40:37 +08:00
DennisandClaude Opus 4.8 d2e36a76e2 fix(shortcut): address review — minutes taskUuid only; English test messages
- minutes: drop the minutesId/minutes_id candidate from the taskUuid mapping.
  minutesId is the minutes document id, a different identifier from the
  recording taskUuid that +record-pause/resume/stop consume via --id, so
  substituting it would feed record control a wrong id. The backend list
  already returns taskUuid; the guard test now asserts taskUuid/task_uuid.
- Rewrite the guard-test failure messages and fixture data in English to match
  the repository convention (only the two assertions that match the
  production Chinese validation string are kept).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 15:20:00 +08:00
Dennis 52cf261000 Merge remote-tracking branch 'origin/main' into fix/shortcut-projection-data-loss 2026-07-24 14:46:54 +08:00
炳昱 6b9fcf9289 fix(event): preserve nested message context when flattened 2026-07-24 14:26:34 +08:00
修雨 8dac7d5fa5 Merge pull request #708 from anxiangbo/feat/20260714_hrbrain
Feat/20260714 hrbrain
2026-07-24 12:31:42 +08:00
修雨 4543e9935c Merge branch 'main' into feat/20260714_hrbrain 2026-07-24 11:38:50 +08:00
修雨 e79c3ea5b9 Merge pull request #786 from DingTalk-Real-AI/codex/fix-homebrew-publish-identity
fix(release): use designated Homebrew publisher
2026-07-24 11:34:26 +08:00
修雨 ad2ba15a45 fix(release): use designated Homebrew publisher 2026-07-24 11:23:20 +08:00
修雨 74350b3c7b Merge pull request #784 from DingTalk-Real-AI/codex/simplify-release-pipeline
fix(release): make publication retries seamless
2026-07-24 11:17:01 +08:00
修雨 02f66df599 fix(release): make publication retries seamless 2026-07-24 11:05:14 +08:00
anxb 883f082425 fix(changelog): move HR Brain entry to Unreleased
The HR Brain entry was incorrectly placed in the released
[1.0.55-beta.1] section during merge conflict resolution. Move it
back to ## [Unreleased] ### Added since hrbrain has not shipped yet.
2026-07-24 10:27:08 +08:00
DennisandClaude Opus 4.8 b1e7b43f85 fix(shortcut): fix projection-data-loss silent-empty returns
Several read shortcuts returned an empty list with exit 0 and no error
envelope even though the underlying MCP tool returned data, so agents misread
"no data" and made wrong decisions.

Root causes:
- Container key mismatch: the resolver probed the wrong key —
  processCodeList / values / wikiSpaces / itemList / groupList / recentItems /
  emailAccounts / deptUserList / labelUserList / roles / report_list, plus
  get_org_labels grouped labels[] needing a descend.
- Item fields nested under a VO wrapper, not unwrapped: shiftVO / entityVO /
  userInfo.
- Param exceeded a backend limit: todo +created-todos sent pageSize=50 while
  the backend silently returns empty for pageSize>20; now uses the shared pager
  (pageSize=20).

Affected: contact/oa/wiki/drive/minutes/calendar/attendance/chat/report/smart
resolvers. Every fix ships a guard test that feeds the real backend response
shape (and, for minutes, both the taskUuid and minutesId item shapes) and
asserts the projection is non-empty with a usable id.

scripts/shortcut_real_result.py now compares the upper (projection) output
against the lower (raw backend) layer, and record_real_shortcut_run.py captures
the lower layer in memory (persisting only derived counts, never raw PII) so an
exit-0 empty projection over a non-empty backend is scored as
projection-data-loss instead of real-ok. The Python self-test runs in CI via
test/scripts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 10:07:02 +08:00
anxb 571a096004 feat: 组织大脑修复7 2026-07-24 09:59:08 +08:00
anxb 0d3fef0037 feat: 组织大脑修复6 2026-07-24 09:46:31 +08:00
anxb 72934ddc39 Merge branch 'refs/heads/main' into feat/20260714_hrbrain
# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog.json
#	internal/cli/schema_hints/runtime-surface-completeness.json
#	skills/mono/SKILL.md
#	test/fixtures/cli-interface-baseline.txt
2026-07-24 09:43:16 +08:00
修雨 eaf53bc2d2 Merge pull request #781 from DingTalk-Real-AI/automation/homebrew-beta-v1.0.55-beta.2
chore: update Homebrew beta formula for v1.0.55-beta.2
2026-07-23 23:28:52 +08:00
DWS Release Bot 3e148c6745 chore: update beta formula for v1.0.55-beta.2 2026-07-23 11:10:16 +00:00
修雨 07bc528c6c Merge pull request #777 from PeterGuy326/codex/release-v1.0.55-beta.2
chore(release): prepare v1.0.55-beta.2
2026-07-23 18:34:38 +08:00
修雨 7ee87d93ee chore(release): prepare v1.0.55-beta.2 2026-07-23 18:32:35 +08:00
修雨 7b77b4e615 Merge pull request #776 from PeterGuy326/codex/sync-wukong-capabilities-20260723
feat: sync Wukong chat, contact, doc, drive, Markdown, and todo
2026-07-23 18:28:29 +08:00
anxb 5dcf95ce07 Merge remote-tracking branch 'origin/feat/20260714_hrbrain' into feat/20260714_hrbrain 2026-07-23 18:23:35 +08:00
anxb e70b21ae8a Merge branch 'refs/heads/main' into feat/20260714_hrbrain 2026-07-23 18:22:43 +08:00
修雨 897eb6515b Merge branch 'main' into codex/sync-wukong-capabilities-20260723 2026-07-23 18:17:36 +08:00
anxiangbo ad0582ea48 Merge branch 'main' into feat/20260714_hrbrain 2026-07-23 18:10:28 +08:00
修雨 f9443af460 fix: preserve schema compatibility for synced capabilities 2026-07-23 17:43:13 +08:00
修雨 876afcddfb feat: sync Wukong capabilities through 3306c3307 2026-07-23 17:43:12 +08:00
修雨 c771d48d6c Merge pull request #756 from shangguanxuan633-lab/codex/auth-legacy-token-compat
fix(auth): migrate legacy tokens and preserve unresolved accounts
2026-07-23 17:35:47 +08:00
修雨 9e48ef759f Merge remote-tracking branch 'origin/main' into codex/auth-legacy-token-compat 2026-07-23 17:24:36 +08:00
修雨 9fb2b76f9a Merge pull request #775 from PeterGuy326/codex/minimize-release-latency
perf(release): shorten guarded release critical path
2026-07-23 17:14:38 +08:00
上官玄 228c62bc0f docs(changelog): note legacy auth compatibility 2026-07-23 16:35:52 +08:00
修雨 321514ad99 perf(release): shorten guarded release critical path 2026-07-23 16:07:58 +08:00
wxianfeng 5a3617c21d feat(event): flatten group member events 2026-07-23 15:30:49 +08:00
玉澜 0d866911e0 fix: refresh existing MCP metadata 2026-07-23 14:20:23 +08:00
anxb 883f397554 feat: 组织大脑修复5 2026-07-23 14:17:22 +08:00
玉澜 2bf5401f55 fix: load split registry for MCP metadata refresh 2026-07-23 14:16:27 +08:00
玉澜 c76c30a0b7 Merge upstream main into feat/command-surface-naming 2026-07-23 14:12:18 +08:00
anxb 276d5bcd73 Merge branch 'refs/heads/main' into feat/20260714_hrbrain 2026-07-23 14:08:53 +08:00
anxb 8321f1ffea Merge remote-tracking branch 'upstream/main' into feat/20260714_hrbrain
# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog.json
#	internal/cli/schema_hints/runtime-surface-completeness.json
#	test/fixtures/cli-interface-baseline.txt
2026-07-23 14:05:24 +08:00
上官玄 888e4432a3 Merge remote-tracking branch 'upstream/main' into codex/auth-legacy-token-compat 2026-07-23 13:45:07 +08:00
修雨 cb200af3b2 Merge pull request #771 from DingTalk-Real-AI/codex/release-v1.0.55-beta.1
chore(release): prepare v1.0.55-beta.1
2026-07-23 13:43:58 +08:00
修雨 cf5b76de07 chore(release): prepare v1.0.55-beta.1 2026-07-23 13:35:30 +08:00
上官玄 3832e7f5e4 Merge remote-tracking branch 'upstream/main' into codex/auth-legacy-token-compat 2026-07-23 13:35:02 +08:00
上官玄 71f90ee45f test(keychain): cover Windows registry failures 2026-07-23 13:31:23 +08:00
修雨 423e16ced0 Merge pull request #767 from DingTalk-Real-AI/codex/align-chat-file-upload
fix(chat): align local file sending with Wukong
2026-07-23 13:25:10 +08:00
上官玄 0d175c4d53 test(auth): isolate Windows credential fixtures 2026-07-23 13:20:43 +08:00
上官玄 a5a6a0f2ce test(auth): close legacy compatibility coverage gaps 2026-07-23 13:01:10 +08:00
修雨 c1a4bd6781 fix(chat): preserve interface while retiring discovery 2026-07-23 13:00:05 +08:00
修雨 02817bc043 Merge main and complete chat media retirement 2026-07-23 12:56:11 +08:00
上官玄 b08f0f77e3 Merge remote-tracking branch 'upstream/main' into codex/auth-legacy-token-compat 2026-07-23 12:22:11 +08:00
上官玄 6d7cc41284 fix(auth): harden legacy token compatibility 2026-07-23 12:21:58 +08:00
修雨 9f76c1844a Merge pull request #697 from FloralTide/feat/mcp-url-get
feat(mcp): add URL resolution command
2026-07-23 11:59:34 +08:00
炳昱 857d9b8c1b test(mcp): cover URL command error paths 2026-07-23 11:31:12 +08:00
anxb 5bdaad092a feat: 组织大脑修复,add hrbrain command nodes to interface baseline)。 2026-07-23 10:42:11 +08:00
anxb 55cf6cfb71 Merge branch 'refs/heads/main' into feat/20260714_hrbrain
# Conflicts:
#	CHANGELOG.md
2026-07-23 10:38:38 +08:00
炳昱 a7fdcea086 test(cli): update public interface baseline 2026-07-23 10:19:14 +08:00
上官玄 1bc17bc4bd Merge remote-tracking branch 'upstream/main' into codex/auth-legacy-token-compat 2026-07-23 00:59:31 +08:00
炳昱 9fc63b6405 fix(mcp): expose URL command in schema 2026-07-23 00:46:14 +08:00
炳昱 3233e1fe93 feat(mcp): add URL resolution command 2026-07-23 00:46:14 +08:00
修雨 f7e61feacf Merge remote-tracking branch 'origin/main' into codex/align-chat-file-upload
# Conflicts:
#	CHANGELOG.md
2026-07-23 00:45:11 +08:00
修雨 cdc3fbe328 test(chat): cover ID routing helpers 2026-07-23 00:38:50 +08:00
Dennis4477 b4ea1f168d fix(chat): render cards, forwards and encrypted messages
Normalize message projections across read shortcuts, preserve mixed user JSON, expand forwarded records, mask ciphertext, and accept media-download message ID aliases while retaining the Cobra/Schema required contract.
2026-07-23 00:18:46 +08:00
修雨 b03017997d fix(schema): preserve chat interface contract 2026-07-23 00:11:41 +08:00
修雨 902e084d8a fix(chat): align local file sending with wukong 2026-07-23 00:03:58 +08:00
上官玄 ccb69f93b8 fix(auth): preserve legacy login state across token backends 2026-07-23 00:01:09 +08:00
修雨 412e77f215 Merge pull request #763 from DingTalk-Real-AI/codex/retry-gitee-transient-outages
fix: retry transient Gitee read outages safely
2026-07-22 17:56:50 +08:00
修雨 2a0bf1ebea fix: retry transient Gitee read outages safely 2026-07-22 17:46:03 +08:00
修雨 9ce13da6ed Merge pull request #762 from DingTalk-Real-AI/codex/extend-gitee-upload-window
fix: extend Gitee upload window
2026-07-22 16:50:49 +08:00
修雨 0e5731166b fix: extend Gitee upload window 2026-07-22 16:39:55 +08:00
anxb 58b4d6f9f4 Merge branch 'refs/heads/main' into feat/20260714_hrbrain 2026-07-22 16:38:40 +08:00
anxb a3478ad587 feat: 组织大脑修复4 2026-07-22 16:31:10 +08:00
anxb 5d1092da73 Merge branch 'refs/heads/main' into feat/20260714_hrbrain 2026-07-22 16:09:29 +08:00
修雨 92edd8ea53 Merge pull request #761 from DingTalk-Real-AI/codex/fix-gitee-slow-upload-timeout
fix: allow slow Gitee binary uploads
2026-07-22 16:08:28 +08:00
修雨 931d75beaf fix: allow slow Gitee binary uploads 2026-07-22 15:57:21 +08:00
修雨 7667cb30a3 Merge pull request #759 from DingTalk-Real-AI/codex/fix-gitee-upload-expect
fix: disable Expect for Gitee uploads
2026-07-22 15:13:33 +08:00
修雨 015daae064 fix: disable Expect for Gitee uploads 2026-07-22 15:02:13 +08:00
修雨 e7510ea5f0 Merge pull request #758 from DingTalk-Real-AI/codex/fix-gitee-upload-timeouts
fix: harden Gitee release repair
2026-07-22 14:39:15 +08:00
修雨 582b73cb40 fix: harden Gitee release repair 2026-07-22 14:27:47 +08:00
anxb 46fe02f72c feat: 组织大脑修复3 2026-07-22 14:24:14 +08:00
修雨 04ea184ff6 Merge pull request #752 from DingTalk-Real-AI/automation/homebrew-beta-v1.0.54-beta.2
chore: update Homebrew beta formula for v1.0.54-beta.2
2026-07-22 14:12:31 +08:00
anxb 2dba64b880 feat: 组织大脑修复2 2026-07-22 13:56:13 +08:00
wxianfeng 1c9b09b23f Merge remote-tracking branch 'upstream/main' into feature/dws-event-im-phase3
# Conflicts:
#	internal/app/event_command.go
#	internal/app/event_personal_command.go
#	internal/cli/schema_agent_metadata/event.json
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog.json
#	internal/cli/schema_hints/selection/event.json
#	internal/event/personal/registry_test.go
#	skills/mono/references/products/event.md
#	skills/multi/dingtalk-event/SKILL.md
#	skills/multi/dingtalk-event/references/event-im.md
2026-07-22 11:54:32 +08:00
修雨 0908b2ca6e Merge branch 'main' into automation/homebrew-beta-v1.0.54-beta.2 2026-07-22 11:48:29 +08:00
修雨 070febd7bf Merge pull request #755 from DingTalk-Real-AI/automation/homebrew-v1.0.54
chore: update Homebrew formula for v1.0.54
2026-07-22 11:47:19 +08:00
anxb e564c8d923 Merge branch 'refs/heads/main' into feat/20260714_hrbrain
# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog.json
2026-07-22 11:09:37 +08:00
DWS Release Bot e3782231be chore: update formula for v1.0.54 2026-07-21 16:07:10 +00:00
DWS Release Bot 167a547a65 chore: update beta formula for v1.0.54-beta.2 2026-07-21 15:55:51 +00:00
修雨 8f62c19104 Merge pull request #749 from DingTalk-Real-AI/release/changelog-v1.0.54-beta.2
docs(changelog): add v1.0.54-beta.2 section
2026-07-21 23:37:15 +08:00
修雨 82798dc7fc docs(changelog): add v1.0.54-beta.2 section 2026-07-21 23:35:36 +08:00
修雨 3319cf62d5 Merge pull request #748 from DingTalk-Real-AI/release/changelog-v1.0.54
docs(changelog): fold v1.0.54-beta.1 into v1.0.54 stable section
2026-07-21 23:28:45 +08:00
修雨 1626818a98 docs(changelog): retain released v1.0.54-beta.1 section under v1.0.54 2026-07-21 23:26:23 +08:00
修雨 a03d6ebacc docs(changelog): fold v1.0.54-beta.1 into v1.0.54 stable section 2026-07-21 23:23:40 +08:00
修雨 4ee4a44e16 Merge pull request #745 from DingTalk-Real-AI/release/changelog-v1.0.54-beta.1
docs(changelog): add v1.0.54-beta.1 section
2026-07-21 23:00:03 +08:00
修雨 40181f8c0c docs(changelog): add v1.0.54-beta.1 section 2026-07-21 22:57:59 +08:00
修雨 14ff02ebe1 Merge pull request #743 from wxianfeng/fix/event-data-format-compat
fix(event): make flattened output opt-in
2026-07-21 22:50:21 +08:00
wxianfeng 55574fe12e Merge upstream/main into fix/event-data-format-compat 2026-07-21 22:37:26 +08:00
修雨 222ee16d51 test(event): close changed-code coverage gaps for flatten output mode
Drop the unreachable defensive tag-skip branch in transportEnvelopeSchema
(every transport.Event field carries a non-empty JSON tag) and add a unit
test for the validatePersonalEventOutputMode success path so the changed
code coverage gate reaches 100%.
2026-07-21 22:28:54 +08:00
修雨 27ced3ee18 Merge pull request #701 from DingTalk-Real-AI/codex/fix-plugin-command-registration
fix: restore plugin CLI overlay commands
2026-07-21 22:03:08 +08:00
修雨 129e8a10ef Merge remote-tracking branch 'origin/main' into codex/fix-plugin-command-registration
# Conflicts:
#	CHANGELOG.md
2026-07-21 21:50:37 +08:00
修雨 02fba09c1e fix(plugin): let replaceable fallbacks pass distribution conflict checks
pluginDescriptorConflictsWithDistribution and the identity-owner seeding
both treated conference as distribution-owned, so the whole plugin server
was skipped before the replaceable-fallback merge in addPluginCommandsSafe
could run. Skip replaceablePluginFallbacks names in both early gates while
keeping reserved-command protection and plugin-vs-plugin ownership intact.
2026-07-21 21:49:47 +08:00
修雨 94b64f74ac Merge pull request #738 from typefield/fix/schema-cli-path-compat
fix(schema): accept compatible CLI path separators
2026-07-21 21:37:10 +08:00
wxianfeng cefcf5b409 fix(event): make flattened output opt-in 2026-07-21 21:25:10 +08:00
玉澜 441289cdfe Merge remote-tracking branch 'upstream/main' into fix/schema-cli-path-compat 2026-07-21 20:50:37 +08:00
玉澜 d03823d772 test(schema): cover unknown compatibility query 2026-07-21 20:50:34 +08:00
修雨 c16a377863 Merge branch 'main' into codex/fix-plugin-command-registration 2026-07-21 20:47:48 +08:00
修雨 31c3acc94b Merge pull request #718 from DingTalk-Real-AI/cleanup/remove-shortcut-eval-pii
chore: 移除含真实 PII 的 shortcut 评测产物
2026-07-21 20:47:19 +08:00
修雨 f7e702df8d Merge branch 'main' into codex/fix-plugin-command-registration 2026-07-21 20:35:02 +08:00
修雨 7fd40ea19a Merge branch 'main' into cleanup/remove-shortcut-eval-pii 2026-07-21 20:34:48 +08:00
玉澜 bee246e62c Merge remote-tracking branch 'upstream/main' into fix/schema-cli-path-compat 2026-07-21 19:50:20 +08:00
玉澜 089caa92a8 test(schema): cover prefixed compatibility query 2026-07-21 19:41:39 +08:00
修雨 2f0f32f56f Merge pull request #739 from DingTalk-Real-AI/fix/release-artifact-raw-version-check
fix(release): verify packaged artifact versions from raw binary bytes
2026-07-21 19:25:39 +08:00
修雨 068d9ff2f5 fix(release): verify packaged artifact versions from raw binary bytes 2026-07-21 19:25:14 +08:00
wxianfeng 4cded1ef6c Merge remote-tracking branch 'upstream/main' 2026-07-21 19:24:43 +08:00
玉澜 21cd3f8bc4 fix(schema): accept compatible CLI path separators 2026-07-21 19:18:07 +08:00
修雨 418928b9a5 Merge pull request #736 from DingTalk-Real-AI/chore/changelog-v1.0.53-stable
docs(changelog): finalize v1.0.53 stable section
2026-07-21 19:00:26 +08:00
修雨 b047b2c3c9 docs(changelog): fold post-beta.7 entries into v1.0.53 stable section 2026-07-21 18:59:56 +08:00
修雨 a516e5f54a Merge pull request #735 from sczheng189/codex/fix-stable-version-verification
fix(release): verify package versions from raw binaries
2026-07-21 18:55:54 +08:00
修雨 70e4e75c66 Merge branch 'main' into codex/fix-stable-version-verification 2026-07-21 18:55:31 +08:00
修雨 1116916b24 Merge pull request #734 from DingTalk-Real-AI/revert-732-fix/release-admission-commit-statuses
Revert "fix(release): check commit statuses in Code Admission gates"
2026-07-21 18:53:57 +08:00
修雨 c0c81b4d70 Merge pull request #733 from DingTalk-Real-AI/revert-730-codex/fix-release-version-verifier
Revert "fix(release): validate packaged version at runtime"
2026-07-21 18:53:53 +08:00
修雨 eedc41ac54 Merge branch 'main' into revert-730-codex/fix-release-version-verifier 2026-07-21 18:52:05 +08:00
zhengyubai c14e24569c fix(release): verify package versions from raw binaries 2026-07-21 19:49:18 +09:00
SCzheng 8add2c00cf Revert "fix(release): check commit statuses in Code Admission gates (#732)"
This reverts commit 29dceec5ce.
2026-07-21 19:48:47 +09:00
修雨 29dceec5ce fix(release): check commit statuses in Code Admission gates (#732)
The "AI Behavior" context is reported as a commit status (via
github.rest.repos.createCommitStatus) rather than a check run, but the
Code Admission gates only queried check runs via
github.rest.checks.listForRef. This caused every release to fail with
"missing: AI Behavior" since the context was never found.

Add a commit-status query after the check-run loop in both the preflight
and sealed-commit Code Admission gates. Statuses are merged only for
required contexts not already covered by a check run, preserving the
existing check-run precedence.
2026-07-21 18:48:03 +08:00
SCzheng b78a0dee47 Revert "fix(release): validate packaged version at runtime" 2026-07-21 19:46:32 +09:00
修雨 807191396e Merge pull request #730 from DingTalk-Real-AI/codex/fix-release-version-verifier
fix(release): validate packaged version at runtime
2026-07-21 18:12:40 +08:00
修雨 cce9b798d5 Merge remote-tracking branch 'origin/main' into codex/fix-release-version-verifier 2026-07-21 17:51:46 +08:00
修雨 bfa3a1bf33 Merge pull request #729 from sczheng189/feat/relax-stable-promotion-contract
feat(release): allow stable promotion with commits after the beta baseline
2026-07-21 17:47:53 +08:00
修雨 e154b4ecde fix(release): validate packaged version at runtime 2026-07-21 17:47:02 +08:00
zhengyubai f83c305749 feat(release): allow stable promotion with commits after the beta baseline
Stable releases previously required a byte-identical tree with the
promoted beta (only CHANGELOG.md could differ) and local releases had
to run exactly at the origin/main tip with an atomic main+tag push.
Together these froze main for the whole beta-to-stable window.

Relax both gates while keeping the beta soak mandatory:
- stable still requires an explicit delivered, non-withdrawn beta whose
  commit is an ancestor of the sealed release commit; the tree-identity
  drift check is removed
- local releases accept any clean sealed commit contained in
  origin/main history (any branch or detached HEAD) and push only the
  release tag; command-compatibility checks compare the sealed HEAD,
  matching CI
2026-07-21 18:35:59 +09:00
炳昱 0182060757 fix(skill): advertise group member event triggers 2026-07-21 17:05:43 +08:00
wxianfeng c9cf1cc9c1 feat(event): support multi-event consume 2026-07-21 16:59:36 +08:00
修雨 b898f5c987 Merge pull request #723 from DingTalk-Real-AI/codex/retry-npm-channel-verification
fix(release): wait for npm channel propagation
2026-07-21 15:56:48 +08:00
修雨 20750df20b fix(release): wait for npm channel propagation 2026-07-21 15:46:19 +08:00
修雨 749149b94a Merge pull request #721 from DingTalk-Real-AI/codex/release-v1.0.53
chore(release): prepare v1.0.53
2026-07-21 15:35:50 +08:00
修雨 e5c8ff9acd chore(release): prepare v1.0.53 2026-07-21 15:27:38 +08:00
修雨 706535b41e Merge pull request #717 from DingTalk-Real-AI/codex/fix-release-ref-fingerprint
fix(release): fingerprint allocated tag refs
2026-07-21 15:10:45 +08:00
DennisandClaude Opus 4.8 e15a2c4efb chore: remove shortcut eval artifacts containing real PII
These files were real-backend capture artifacts committed by mistake and
contain personal data — employee names/emails, mail subjects, conversation &
message IDs, contact userIds/org, and hardcoded real test-target IDs:

- docs/shortcut-real-read-results.json   (raw read responses)
- docs/shortcut-real-write-results.json  (raw write responses)
- docs/shortcut-comparison.html          (embeds the raw responses)
- scripts/run_shortcut_real_read_matrix.py (hardcoded real target IDs)

They are dev-only capture artifacts, not build/CI inputs — the checked-in
public_catalog_generated.go is committed and no workflow/Makefile references
them, so removal does not affect the build. The generator scripts under
scripts/ that read these JSONs are local dev tools; they should consume a
locally-provided, uncommitted capture instead.

Add .gitignore rules so these (and the untracked shortcut-gsb-eval.* variants)
can never be re-committed.

Note: this only removes them going forward. They remain in git history on
origin/main (commit 8687d68); scrubbing history requires a separate,
owner-approved filter-repo/force-push.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:09:47 +08:00
anxb 2e7e6a1010 feat: 组织大脑修复 2026-07-21 15:07:59 +08:00
修雨 9e88116a2d fix(release): fingerprint allocated tag refs 2026-07-21 14:55:11 +08:00
修雨 05a306148a Merge pull request #715 from DingTalk-Real-AI/codex/allow-optional-oss-mirror
fix(release): defer unprovisioned OSS mirror
2026-07-21 14:34:27 +08:00
修雨 6c0cf3438b fix: address plugin review blockers 2026-07-21 11:33:03 +08:00
修雨 e3f30420fb fix: restore plugin overlay commands 2026-07-21 11:33:03 +08:00
anxb f88bc32259 feat: 接入组织大脑5 2026-07-21 10:44:57 +08:00
anxb f3cce5f49b Merge branch 'refs/heads/main' into feat/20260714_hrbrain 2026-07-21 10:37:36 +08:00
anxb 2e389fe27d feat: 接入组织大脑4 2026-07-21 09:57:21 +08:00
炳昱 b234015e7f feat(event): add group member lifecycle events 2026-07-20 16:10:47 +08:00
anxb dd112a845e feat: 接入组织大脑3 2026-07-20 14:02:44 +08:00
wxianfeng ca6e520610 chore(event): default personal events to pre-release 2026-07-20 11:47:55 +08:00
wxianfeng b731050dad feat(event): add all-message and group lifecycle events 2026-07-20 11:32:46 +08:00
wxianfeng bb2dd2ba76 Merge remote-tracking branch 'upstream/main' into feature/dws-event-im-phase3 2026-07-20 10:08:19 +08:00
anxb c0cb81c12b Merge branch 'refs/heads/main' into feat/20260714_hrbrain 2026-07-20 09:56:49 +08:00
玉澜 51dc237d8a feat: declarative LeafSpec command framework + schema generation/consumption separation
== LeafSpec command framework (internal/helpers/leaf.go) ==
Declarative command construction: LeafSpec/LeafFlag/NewLeafCommand with
Call (pluggable dispatch), LeafInt, PostMount, Trim, Validate. Collapses
per-command hand-written required validation, alias/env fallback, value
transform, and toolArgs assembly into one declarative path.

== devapp migration (28/31 commands) ==
All MCP-direct devapp leaf commands migrated to LeafSpec. Factories
(devAppCall/devAppCallCursor/devAppMeta) fold 33 repeated closures.
fakeDevAppRunner asserts toolArgs for every migrated command. 4 complex
commands (delete/robot submit/result/config) kept hand-written.

== Schema generation/consumption separation ==
- gen.go: isolated //go:generate pragmas from business code.
- command_meta.go: ResolveMeta(cliPath) -> CommandMeta{Identity,Safety,Selection}.
- command_safety.go: SafetyForCLIPath + RenderSafetyAnnotation; safety metadata
  flows from embedded catalog into --help output.
- calendar.go HelpFunc fix: delegates to root HelpFunc at help-time.
- schema_catalog_structure.go: closed catalog structure validation gate.

== Registry + catalog per-product sharding ==
schema_command_registry and schema_catalog split into per-product shards,
eliminating concurrent-PR merge conflicts on these files.

== MCP metadata refresh tool ==
cmd/fetch_mcp_metadata: iterates 26 MCP server endpoints, merges with previous
data for cross-server interface_ref. make fetch-mcp-metadata target.

== AGENTS.md ==
Documents the generation/consumption split.

Verified: make policy exit 0, drift zero, all tests pass.
2026-07-19 09:38:43 +08:00
wxianfeng 049af9fc30 Merge remote-tracking branch 'upstream/main' 2026-07-17 17:38:04 +08:00
wxianfeng d19a15a6ed Merge branch 'main' of github.com:wxianfeng/dingtalk-workspace-cli
# Conflicts:
#	.github/badges/coverage.svg
2026-07-17 17:36:26 +08:00
anxb 2b76047164 Merge branch 'refs/heads/main' into feat/20260714_hrbrain 2026-07-17 14:41:05 +08:00
anxb 241444e992 feat: 接入组织大脑2 2026-07-17 14:20:36 +08:00
玉澜 52045fb290 Merge upstream/main into agent/sync-wukong-multi-skill 2026-07-16 18:17:17 +08:00
anxb 309f833f15 Merge branch 'refs/heads/main' into feat/20260714_hrbrain 2026-07-16 10:19:44 +08:00
anxb 115cce7308 feat: 接入组织大脑 2026-07-16 10:18:05 +08:00
玉澜 275c3430b8 fix(skills): reconcile multi-skill runtime contracts 2026-07-15 10:26:51 +08:00
玉澜 56116bf99e feat(skills): align Wukong multi-skill docs 2026-07-15 01:17:45 +08:00
github-actions[bot] e85d9bc314 chore: update coverage badge [skip ci] 2026-07-13 02:36:36 +00:00
734 changed files with 771643 additions and 489942 deletions
-4
View File
@@ -1,4 +0,0 @@
# Default code owners for all files
# These users will be automatically requested for review on PRs.
* @DingTalk-Real-AI/cli-maintainers
+28 -9
View File
@@ -3,22 +3,41 @@
- What changed?
- Why is this change needed?
## Risk tier
- [ ] Documentation-only: prose/assets only; no executable, generated, workflow,
packaging, or interface behavior changed
- [ ] Standard: ordinary implementation change with a stable package graph
- [ ] High-risk: workflow/policy, package graph, generated Schema/registry,
platform, auth/keychain, installer, packaging, release, transport, recovery,
or another fail-closed infrastructure change
## Verification
For an exact in-place `CHANGELOG.md`-only pull request, the full-suite checks
may be marked `N/A`, but the targeted CHANGELOG check is required. For every
other pull request, mark the targeted check `N/A` and complete the applicable
full-suite checks.
Record the smallest targeted evidence that proves the changed behavior. Do not
repeat the entire CI suite locally only to fill this checklist: CI expands the
selected tier from documentation checks, through affected-package tests, to
the complete high-risk suite.
- [ ] Exact `CHANGELOG.md`-only check (otherwise `N/A`):
- [ ] Exact in-place `CHANGELOG.md`-only check (otherwise `N/A`):
`./scripts/policy/check-changelog-pr.sh --fast-path "$(git merge-base HEAD origin/main)" HEAD`
- [ ] `make build`
- [ ] `make lint`
- [ ] `make test`
- [ ] `make policy`
- [ ] Targeted test/check commands and results:
- [ ] Behavior evidence (test name, CLI output shape, or before/after result):
- [ ] Documentation links/content/rendering checked (documentation-only, otherwise
`N/A`)
- [ ] Full local suite run because the change is high-risk (optional for other
tiers; record command/result or `N/A`)
- [ ] `./scripts/policy/check-generated-drift.sh`
(when generator inputs or generated artifacts may change)
- [ ] `./scripts/policy/check-command-surface.sh --strict` (if command surface changed)
- [ ] `./scripts/release/verify-package-managers.sh`
(after `make package`, if packaging or installer surfaces changed)
## Notes
- Any risks, follow-up work, or intentional scope cuts
The repository automatically requests one eligible peer reviewer, including
after a new head push when another review is needed. Once the latest push has
peer approval and all nine required checks are current and green, auto-merge
completes the PR; authors do not need to coordinate a separate routine merge.
+605 -108
View File
File diff suppressed because it is too large Load Diff
+44 -1
View File
@@ -14,6 +14,11 @@ on:
schedule:
- cron: '0 18 * * *'
workflow_dispatch:
inputs:
sync_release_version:
description: "Sync a specific release version's assets to Gitee (e.g. v1.0.55-beta.3)"
required: false
type: string
concurrency:
group: gitee-code-mirror
@@ -23,7 +28,6 @@ jobs:
mirror:
runs-on: ubuntu-latest
if: ${{ github.ref_name == github.event.repository.default_branch && github.repository_owner == 'DingTalk-Real-AI' }}
# GitHub Actions 不允许在 job-level if 直接引用 secrets,故先用 env 暴露再在 step 守卫。
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
@@ -76,3 +80,42 @@ jobs:
# main 镜像对齐;release tag 由 release.yml 单独校验后创建,禁止在这里 force。
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
echo "✅ 已镜像 main(含 Gitee README 本地化)到 Gitee ${GITEE_REPO}"
- name: Download GitHub Release assets
if: ${{ inputs.sync_release_version != '' }}
env:
VERSION: ${{ inputs.sync_release_version }}
GH_TOKEN: ${{ github.token }}
run: |
set -eu
echo "📥 Downloading release assets for ${VERSION}"
mkdir -p dist
gh release download "$VERSION" \
--repo "$GITHUB_REPOSITORY" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
ls -la dist/
- name: Verify release artifacts
if: ${{ inputs.sync_release_version != '' }}
env:
VERSION: ${{ inputs.sync_release_version }}
run: |
set -eu
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
./scripts/release/verify-release-artifacts.sh "$VERSION"
- name: Sync release assets to Gitee
if: ${{ inputs.sync_release_version != '' }}
env:
VERSION: ${{ inputs.sync_release_version }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
DIST_DIR: ${{ github.workspace }}/dist
run: |
set -eu
echo "📦 Syncing release assets for ${VERSION} to Gitee ${GITEE_REPO}"
./scripts/release/sync-to-gitee.sh
File diff suppressed because it is too large Load Diff
+258
View File
@@ -0,0 +1,258 @@
name: Reviewer routing
on:
pull_request_target:
branches: [main]
types: [opened, synchronize, reopened, ready_for_review]
# pull_request_target deliberately runs only this workflow from the protected
# base branch. Never check out or execute pull-request code here.
permissions:
contents: write
pull-requests: write
concurrency:
group: reviewer-router-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
route:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Route review and enable auto-merge
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const pullNumber = context.payload.pull_request.number;
const eventHeadSha = context.payload.pull_request.head.sha;
const reviewerPool = [
'sczheng189',
'shangguanxuan633-lab',
'audanye-sudo',
'wxianfeng',
];
async function getReadyEventPull(phase) {
const {data: currentPull} = await github.rest.pulls.get({
owner,
repo,
pull_number: pullNumber,
});
if (
currentPull.head.sha !== eventHeadSha ||
currentPull.state !== 'open' ||
currentPull.draft ||
currentPull.base.ref !== 'main'
) {
core.info(
`PR #${pullNumber} state or revision no longer matches this ready-main event during ${phase}; routing stopped.`,
);
return null;
}
return currentPull;
}
const pullRequest = await getReadyEventPull('initial read');
if (!pullRequest) {
return;
}
const author = pullRequest.user.login.toLowerCase();
const headSha = pullRequest.head.sha;
const latestPusher =
context.payload.action === 'synchronize'
? context.payload.sender?.login?.toLowerCase()
: author;
async function routeReview() {
const eligible = reviewerPool.filter(
reviewer =>
reviewer.toLowerCase() !== author &&
reviewer.toLowerCase() !== latestPusher,
);
if (eligible.length === 0) {
core.warning(`No eligible reviewer remains for PR #${pullNumber}.`);
return;
}
const alreadyRequested =
(pullRequest.requested_reviewers || []).length > 0 ||
(pullRequest.requested_teams || []).length > 0;
if (alreadyRequested) {
core.info(`PR #${pullNumber} already has a requested reviewer; leaving it unchanged.`);
return;
}
let reviews;
try {
reviews = await github.paginate(github.rest.pulls.listReviews, {
owner,
repo,
pull_number: pullNumber,
per_page: 100,
});
} catch (error) {
core.warning(
`Could not inspect existing reviews for PR #${pullNumber}; skipping reviewer routing to avoid a duplicate request (${error.status || 'unknown status'}).`,
);
return;
}
const latestDecisionByLogin = new Map();
for (const review of reviews) {
const login = review.user?.login?.toLowerCase();
if (
!login ||
!['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(
review.state,
)
) {
continue;
}
const previous = latestDecisionByLogin.get(login);
if (!previous || review.id > previous.id) {
latestDecisionByLogin.set(login, review);
}
}
const currentHeadDecision = [...latestDecisionByLogin.values()].find(
review =>
review.commit_id === headSha &&
eligible.some(
reviewer =>
reviewer.toLowerCase() ===
review.user.login.toLowerCase(),
) &&
['APPROVED', 'CHANGES_REQUESTED'].includes(review.state),
);
if (currentHeadDecision) {
core.info(
`PR #${pullNumber} already has a ${currentHeadDecision.state} review on its current head; leaving review ownership unchanged.`,
);
return;
}
const loads = new Map(eligible.map(reviewer => [reviewer, 0]));
try {
const openPullRequests = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
for (const openPullRequest of openPullRequests) {
for (const reviewer of openPullRequest.requested_reviewers || []) {
const candidate = eligible.find(
login => login.toLowerCase() === reviewer.login.toLowerCase(),
);
if (candidate) {
loads.set(candidate, loads.get(candidate) + 1);
}
}
}
} catch (error) {
core.warning(
`Could not read current reviewer load; using deterministic rotation (${error.status || 'unknown status'}).`,
);
}
const offset = pullNumber % eligible.length;
const rotated = eligible.slice(offset).concat(eligible.slice(0, offset));
const tieOrder = new Map(rotated.map((reviewer, index) => [reviewer, index]));
const staleChangeRequester = [...latestDecisionByLogin.values()]
.filter(review => review.state === 'CHANGES_REQUESTED')
.sort((left, right) => right.id - left.id)
.map(review =>
eligible.find(
reviewer =>
reviewer.toLowerCase() === review.user.login.toLowerCase(),
),
)
.find(Boolean);
const ranked = [...eligible].sort(
(left, right) =>
Number(right === staleChangeRequester) -
Number(left === staleChangeRequester) ||
loads.get(left) - loads.get(right) ||
tieOrder.get(left) - tieOrder.get(right),
);
for (const reviewer of ranked) {
try {
const currentPull = await getReadyEventPull('review request');
if (!currentPull) {
return;
}
if (
(currentPull.requested_reviewers || []).length > 0 ||
(currentPull.requested_teams || []).length > 0
) {
core.info(
`PR #${pullNumber} received a reviewer while routing; leaving it unchanged.`,
);
return;
}
await github.rest.pulls.requestReviewers({
owner,
repo,
pull_number: pullNumber,
reviewers: [reviewer],
});
core.info(
`Requested @${reviewer} for PR #${pullNumber} (open request load: ${loads.get(reviewer)}).`,
);
return;
} catch (error) {
core.warning(
`Could not request @${reviewer} for PR #${pullNumber}; trying the next candidate (${error.status || 'unknown status'}).`,
);
}
}
core.warning(`No reviewer request could be created for PR #${pullNumber}.`);
}
async function enableAutoMerge() {
try {
const currentPull = await getReadyEventPull('auto-merge enable');
if (!currentPull) {
return;
}
if (currentPull.auto_merge) {
core.info(`Auto-merge is already enabled for PR #${pullNumber}.`);
return;
}
await github.graphql(
`mutation EnableAutoMerge($pullRequestId: ID!) {
enablePullRequestAutoMerge(
input: {
pullRequestId: $pullRequestId
mergeMethod: MERGE
}
) {
pullRequest {
autoMergeRequest {
enabledAt
}
}
}
}`,
{pullRequestId: currentPull.node_id},
);
core.info(`Enabled native auto-merge for PR #${pullNumber}.`);
} catch (error) {
core.warning(
`Could not enable auto-merge for PR #${pullNumber}; checks and review can continue normally (${error.message}).`,
);
}
}
try {
await routeReview();
} catch (error) {
core.warning(
`Reviewer routing hit an unexpected error for PR #${pullNumber}; review can still proceed manually (${error.message}).`,
);
}
await enableAutoMerge();
+12
View File
@@ -54,3 +54,15 @@ test/dev_functional/results.jsonl
/coverage-policy.txt
/coverage.html
dwsbin
# Local shortcut eval / real-backend capture artifacts — may contain real PII
# (employee names/emails, userIds, conversation & message IDs). Never commit.
/docs/shortcut-real-read-results.json
/docs/shortcut-real-write-results.json
/docs/shortcut-comparison.html
/docs/shortcut-gsb-eval.*
/scripts/run_shortcut_real_read_matrix.py
# Local coverage artifacts
coverage-shortcut.txt
coverage-*.txt
+41 -5
View File
@@ -7,7 +7,8 @@ unrelated work, and use `gofmt` for every modified Go file.
- Build: `go build ./cmd`
- Full test suite: `DWS_PACKAGE_VERSION=0.0.0-test go test ./...`
- Generate Schema assets: `go generate ./internal/cli`
- Generate Schema assets: `go generate ./internal/cli` (entry point: `internal/cli/gen.go`)
- Refresh pinned MCP metadata: `make fetch-mcp-metadata` (requires `dws auth login`)
- Check generated drift: `./scripts/policy/check-generated-drift.sh`
- Check the Schema contract: `./scripts/policy/check-schema-catalog.sh`
@@ -56,8 +57,16 @@ The Schema data flow is one way:
6. One-way publication
SchemaRegistry
└─ internal/cli/schema_catalog.json
└─ internal/cli/schema_catalog/
(catalog.json + tools/<product>.json; split per product so
concurrent feature PRs only rewrite their own shard)
└─ dws schema list/product/group/leaf/--all
7. Runtime consumption (unified API)
ResolveMeta(cliPath) → CommandMeta{Identity, Safety, Selection}
└─ internal/cli/command_meta.go
└─ all consumers (help, schema, agent, skill-gen) call this one function
└─ backed by embedded catalog (sync.Once lazy map, O(1) lookup)
```
Parameter overlays from metadata are merged into `EffectiveCommandRegistry`
@@ -73,11 +82,37 @@ Build-time gates and the snapshot serializer consume that source-resolved typed
registry/index. Runtime projections and delivery gates consume the typed
registry/index returned by the production snapshot loader. Neither path may
reopen annotations, merge source records, or use a previous Catalog or other
generated JSON as a source. `schema_catalog.json` is output-only in the
generated JSON as a source. `schema_catalog/` (catalog.json + per-product
tools/<product>.json shards) is output-only in the
generation graph. The production loader decoding the embedded published
snapshot is a delivery boundary, not source resolution; it must never create or
repair a Cobra command, flag, registry entry, or later Catalog generation.
### Generation vs consumption separation
The Schema system has two physically separated processes:
**Generation** (build-time, slow, reviewed, one-way):
- Entry point: `internal/cli/gen.go` (all `//go:generate` pragmas isolated here,
not in business code).
- Tools: `internal/generator/cmd_schema_agent_metadata` + `cmd_schema_catalog`
(standalone Go mains).
- Inputs: 6 authored sources (registry + hints metadata + hints selection +
MCP metadata + parameter bindings + cobra tree).
- Output: `schema_catalog/` (per-product shards) + `schema_agent_metadata/`.
- Refresh MCP metadata: `make fetch-mcp-metadata` (iterates 26 MCP server
endpoints, merges with previous data for cross-server interface_ref).
- Gates: `make generate-schema` (byte guards on inputs), `check-generated-drift.sh`,
`check-command-surface.sh` (catalog structure).
**Consumption** (runtime, fast, read-only, unified API):
- Entry point: `ResolveMeta(cliPath) → CommandMeta{Identity, Safety, Selection}`
in `internal/cli/command_meta.go`.
- Backed by embedded catalog (`embeddedSchemaCatalog()`, sync.Once, O(1) map).
- Consumers: `--help` (Safety annotation via `RenderSafetyAnnotation`),
`dws schema`, agent selection, future skill generation.
- `SafetyForCLIPath` delegates to `ResolveMeta` (backward compatible).
This split is architecturally isomorphic to Lark's typed metadata registry,
navigation catalog, and schema renderer. DWS intentionally preserves its
existing flat JSON wire contract for compatibility; do not treat architectural
@@ -117,7 +152,8 @@ When adding or changing an Agent-visible command, review all relevant inputs:
must never materialize, infer, or override registry identity.
- Flag-to-interface property mappings and required/default semantics.
- Generated files under `internal/cli/schema_agent_metadata/` and
`internal/cli/schema_catalog.json` after running generation.
`internal/cli/schema_catalog/` (catalog.json + tools/<product>.json) after
running generation.
Run the reverse-completeness tests whenever the Cobra tree changes. A command
that works through `dws <path>` but cannot be found through the matching
@@ -177,7 +213,7 @@ For every curated tool:
2. Edit `selection/<product>.json` for selection prose (`reviewed: true`,
`review_reason`, `source_refs`).
3. Run `make generate-schema`. Do not hand-edit generated
`schema_agent_metadata/` or `schema_catalog.json`.
`schema_agent_metadata/` or `schema_catalog/`.
### Pull live MCP descriptions (personal token)
+169
View File
@@ -6,6 +6,174 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.55-beta.6] - 2026-07-29
This beta packages PRs #621, #676, #757, #815, and #816, validating the Wukong
capability and multi-Skill synchronization, declarative command and Schema
delivery, hardened Chat shortcuts, external contact resolution, and Agent
product identity on top of the `v1.0.55-beta.5` baseline.
### Added
- **Wukong capability and multi-Skill synchronization** (#621) — ports roughly 30 reviewed leaf commands into the open-source CLI across Drive, Doc, Sheet, and Chat, including in-place text-emotion updates, Drive version and permission operations, document styling, and Sheet comment/version/formula verification. The bundled multi-Skill framework is reorganized into progressive product references and routing guidance while retaining current open-source command, response, safety, and Runtime Schema contracts.
- **Declarative leaf commands and unified metadata delivery** (#676) — adds the reusable `LeafSpec` command framework and migrates 27 DevApp commands without changing their paths or flags. Runtime consumers now resolve identity, safety, and selection through one embedded Catalog-backed API, and guarded Help output publishes the command's safety/confirmation annotation.
- **Agent product identity** (#816) — adds the optional `DWS_AGENT_PRODUCT` override for the existing HTTP `claw-type` header while preserving each edition's default when unset. Product and runtime labels are caller-declared signals, not authentication credentials; services must validate supported values and must not grant access solely from them. The override does not change the separate IM message-display `clawType` parameter controlled by the edition and `--ai-tag`.
### Changed
- **Reviewed Chat shortcut delivery** (#815) — publishes 88 currently available Chat shortcuts after real-business validation, keeps three confirmed lower-service failures unavailable, strengthens semantic availability and dry-run contracts, and adds safe message-resource download plus group-member listing. Conversation filtering, IM routing/reporting, and member mute resolution are aligned with the validated backend identities.
- **Agent identity label hardening** (#816) — limits `DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` to 64 ASCII bytes, trims only surrounding ASCII spaces and tabs, and rejects other control or Unicode whitespace. QwenWork integrations should report the two dimensions separately as `DWS_AGENT_PRODUCT=qwenwork` plus `DWS_AGENT_HOST=cloud` or `desktop`; previously used combined Host labels such as `qwenwork_cloud` remain syntactically valid for compatibility.
### Fixed
- **External-contact and message-resource chaining** (#757) — the shared name-to-ID resolver keeps external or cross-organization contacts that expose only `openDingTalkId`, applies reviewed display-name fallbacks, and preserves organization-only filtering for commands that require `userId`. `chat +messages-resource-url` now accepts `--msg-id` and `--open-message-id` as aliases for `--message-id`, matching message-list response fields.
## [1.0.55-beta.5] - 2026-07-28
This beta validates expanded personal event consumption, complete Agent-visible
Runtime Schema coverage for all 210 built-in shortcuts, Agent host
observability, and hardened document, Drive, approval, and Todo command
contracts on top of the `v1.0.55-beta.4` baseline.
### Added
- **Expanded personal event consumption** (#790) — adds eight IM personal event keys, supports subscribing to and consuming multiple event keys in one `dws event consume` invocation, and adds targeted local-consumer shutdown when a subscription is stopped so other consumers can continue on the shared event bus.
- **Shortcut Runtime Schema delivery** (#802) — publishes all 210 public built-in shortcuts as reviewed Agent-visible leaf tools across 16 product groups, with stable canonical identities, executable `+shortcut` CLI paths, parameter and cross-parameter constraints, selection guidance, interface metadata, and runtime-aligned safety/confirmation semantics. `dws shortcut list` remains the lightweight batch-discovery view, while leaf Schema now carries the complete Agent contract; declared string-slice defaults are also preserved consistently in Cobra and Schema.
- **Agent host observability** (#804) — accepts an optional, validated `DWS_AGENT_HOST` label and sends it as `x-dws-agent-host` for logs and BI only; invalid values fail before CLI network activity, and the label never participates in authentication or routing.
### Fixed
- **Command contract edge cases** (#803) — approval revocation and document-version rollback now honor `--dry-run` before confirmation or remote preflight; `drive rename` removes only a suffix matching the node's current extension to avoid duplicate extensions while `doc rename` preserves the caller's exact display name; `doc info` keeps its stable MCP contract while `drive info` restores Drive-only metadata such as a non-null `fileSize`; and Todo reminder writes now reject invalid rule JSON while Help, Schema, and Skills distinguish a due time from an independently unreadable reminder rule.
## [1.0.55-beta.4] - 2026-07-27
This beta validates the shortcut projection fixes for group bots, bot search,
and mail threads, together with hardened release delivery to Gitee and npm on
top of the `v1.0.55-beta.3` baseline.
### Fixed
- **Shortcut projection fixes** (#795) — `chat +chat-bots` no longer projects a non-empty `list_group_bots` response to an empty list, `+bot-find` recognizes the `search_bots` response shape (`result.bots` entries with `botOpenDingTalkId`), and mail thread listings keep `lastUpdated` when the backend returns `lastModifiedDateTime`.
### Changed
- **Hardened release delivery** — the Gitee mirror workflow can synchronize a specific release's assets on demand, release lookup tolerates Gitee's HTTP 200 null-body response for missing releases, npm dist-tag verification waits through slow registry CDN propagation with incremental backoff, and beta/stable release operations are role-enforced (#791).
## [1.0.55-beta.3] - 2026-07-24
This beta validates the HR Brain command surface, smoother guarded release
automation, and deterministic Markdown test coverage on top of the
`v1.0.55-beta.2` baseline.
### Added
- **HR Brain (`dws hrbrain`) command surface** — adds 11 commands across three groups: `talent-pool list/detail/employees` for talent pool browsing, `profile metadata/query/labels/career/performance` for employee profile data, and `search employees/employees-structured/fields` for basic and advanced (rule-based) people search. Ships with bundled mono/multi Skill guidance (`dingtalk-hrbrain`, `cli_version: ">=1.0.54"`); `search employees-structured` validates `--origin-json` as a JSON object and `--fields` as a JSON array before dispatch.
### Changed
- **Smoother guarded releases** — publishes verified stable and beta Homebrew Formula updates directly from the release workflow, retries transient tag-ref visibility failures, lets an exact same-run retry reuse its sealed tag, and allows machine-verified rebuild recovery without a separate approval wait.
### Fixed
- **Deterministic Markdown coverage** — replaces timing-dependent temporary-file deletion tests with synchronized file-stat failures so release admission no longer flakes on scheduler timing.
### Changed
- **Faster guarded releases** — trusts an independently revalidated, exact `CHANGELOG.md`-only successor of an already admitted `main` commit, runs cloud planning alongside governance, and executes sealed-release automation, compatibility, and multi-profile validation in parallel with artifact compilation. Normal cloud publication no longer requires an unshareable local packaging preflight.
- **Scoped document reads and group mentions** — `doc read --content-format jsonml` can return `outline`, `range`, `section`, or custom-tag fragments with depth and block-boundary controls; document comment create, reply, and update can mention groups through `--mentioned-open-conversation-id`.
- **Drive overwrite uploads** — `drive upload --node <fileId>` can replace an existing Drive or document-space file, is mutually exclusive with `--folder`, supports dry-run, and requires confirmation before writing.
- **Chat nickname clearing and cross-organization todos** — omitting `--nick` from `chat group update-nick` now clears the current user's group nickname, while `todo task list --query-all` queries todos across organizations.
### Fixed
- **Legacy authentication compatibility** (#756) — migrates pre-v1.0.53 global and organization-scoped login state into the identity-aware token store, including all legacy organizations, while keeping unresolved accounts isolated from exact `corpId:userId` credentials so external or no-directory identities can complete login without borrowing another user's token.
## [1.0.55-beta.1] - 2026-07-23
This beta validates MCP Market URL resolution, the supported Wukong local-file
send path after retiring the legacy credential-based media upload command from
discovery, and reliable message-read rendering for rich content, forwarded
records, encrypted messages, and media-download ID aliases.
### Added
- **MCP URL resolution** — adds `dws mcp url get <mcpId>` for resolving a DingTalk MCP Market ID to the current user and organization scoped Streamable HTTP URL, while keeping the helper-only `mcp-meta` endpoint out of the public product command surface.
### Changed
- **Chat local-file sending** — hides the open-source-only `chat media upload` compatibility command from Help, Schema, and bundled Skills, and removes its legacy AppKey/AppSecret OAPI path. Historical argv still receives an actionable migration error. Send local images and files through `chat message send --msg-type file --file-path`; callers that already hold a mediaId may continue to use `--msg-type image --media-id`.
### Fixed
- **Shortcut projection silent-empty returns** (#783) — a batch of read shortcuts returned an empty list with exit 0 and no error envelope even when the underlying MCP tool returned data, so agents misread "no data". The projection resolvers now probe the real container keys (`processCodeList`, `values`, `wikiSpaces`, `itemList`, `groupList`, `recentItems`, `emailAccounts`, `deptUserList`, `labelUserList`, `roles`, `report_list`, and the grouped `get_org_labels` `labels[]`), unwrap items nested under a VO wrapper (`shiftVO` / `entityVO` / `userInfo`), and `todo +created-todos` uses the shared pager (`pageSize=20`) because the backend silently returns an empty page for `pageSize>20`. Affects contact/oa/wiki/drive/minutes/calendar/attendance/chat/report/smart shortcuts, each with a guard test asserting the real response shape projects non-empty. `scripts/shortcut_real_result.py` also gains an upper-vs-lower layer comparison so an exit-0 empty projection over a non-empty backend is scored as `projection-data-loss` in the real read-audit path rather than `real-ok`.
- **Message-read shortcut projection** (#706) — the message-list shortcuts (`chat +chat-messages` / `+messages-list` / `+messages-list-direct` / `+at-me` / `+search-msg` / `+thread-replies`) now render card and out-of-office rich-content JSON as readable text (without ever rewriting ordinary text that merely embeds a JSON fragment), expand a forwarded chat record's nested `forwardMessages` instead of collapsing to a "[卡片]" summary, and mark undecryptable encrypted card messages as `[加密消息]`; the speaker is read from the bare `sender` key, nested `{name:…}` sender objects yield their display name, and the literal string `"null"` is treated as absent. Shared projection helpers now live in `internal/shortcut/chatmsg`. `chat message download-media` also gains `--msg-id` / `--open-message-id` aliases for its `--message-id` flag so agents copying the `openMessageId`/`msgId` output field no longer hit "unknown flag".
## [1.0.54] - 2026-07-21
This release promotes the validated `v1.0.54-beta.2` baseline to stable. It restores the default transport envelope for personal event output with opt-in flattening, plus Schema CLI path and plugin overlay compatibility fixes.
### Changed
- **Personal event output compatibility** (#743) — `event consume` once again preserves the transport envelope by default for `ndjson`/`json`/`pretty`, while retaining the existing `compact` processor. New Agent workflows opt into the event-specific top-level DTO with `--flatten`, which is mutually exclusive with `-f raw` and `--debug-raw-events`; `event schema --flatten` describes that DTO, while the default schema describes `type/event_type/data/headers` and points to `.data | fromjson`.
### Fixed
- **Schema CLI path compatibility** (#738) — user-facing Schema lookups once again accept space-, dot-, and slash-separated CLI paths without weakening strict canonical identity resolution.
- **Plugin CLI overlays** (#701) — installed plugins register their manifest-authored command trees again for HTTP and stdio servers, and a plugin may now replace a hidden compatibility fallback (for example `conference`) instead of being skipped as a distribution conflict.
## [1.0.54-beta.2] - 2026-07-21
This beta revalidates the same `v1.0.54-beta.1` source through the cloud release path with a sealed `OSS-Mirror: deferred` policy, because the manually tagged `v1.0.54-beta.1` push run failed on the unavailable OSS mirror channel after GitHub and npm delivery.
### Changed
- **Release delivery only** — no source changes since `v1.0.54-beta.1`; see that section for the user-visible changes under validation (#743, #738, #701).
## [1.0.54-beta.1] - 2026-07-21
This beta validates the restored default transport envelope for personal event output with opt-in flattening, plus Schema CLI path and plugin overlay compatibility fixes, on top of the validated `v1.0.53-beta.7` baseline.
### Changed
- **Personal event output compatibility** (#743) — `event consume` once again preserves the transport envelope by default for `ndjson`/`json`/`pretty`, while retaining the existing `compact` processor. New Agent workflows opt into the event-specific top-level DTO with `--flatten`, which is mutually exclusive with `-f raw` and `--debug-raw-events`; `event schema --flatten` describes that DTO, while the default schema describes `type/event_type/data/headers` and points to `.data | fromjson`.
### Fixed
- **Schema CLI path compatibility** (#738) — user-facing Schema lookups once again accept space-, dot-, and slash-separated CLI paths without weakening strict canonical identity resolution.
- **Plugin CLI overlays** (#701) — installed plugins register their manifest-authored command trees again for HTTP and stdio servers, and a plugin may now replace a hidden compatibility fallback (for example `conference`) instead of being skipped as a distribution conflict.
## [1.0.53] - 2026-07-21
This release promotes the validated `v1.0.53-beta.7` baseline to stable. It adds enterprise onboarding, declarative shortcuts, Sheet/Aitable writes, multi-account profiles, and broader personal IM events, while hardening authentication and the guarded release path.
### Added
- **Enterprise and office command coverage** — adds enterprise creation, employee invitation, and account provisioning commands; 366 declarative service shortcuts; Sheet import commands; and Aitable workflow create/update support with reviewed Schema contracts.
- **Multiple accounts in one DingTalk organization** — profiles can distinguish accounts by organization and user, select them explicitly, and log out one account or an entire organization without overwriting another account's credentials.
- **Expanded personal IM event subscriptions** (#651) — adds read-receipt, recall, and reaction events for one-to-one and group chats, plus specified-sender subscriptions by staff ID or OpenDingTalk ID.
- **Official multi-platform Homebrew channel** — ships separate stable and keg-only beta Formulae for macOS and Linux across amd64 and arm64, with isolated update PRs.
### Changed
- **Personal event output contract** (#651) — `event consume` now emits event-specific top-level structured fields; scripts that consumed the former transport envelope must use the flat fields or select `-f raw`, while `--debug-raw-events` retains the diagnostic envelope.
- **Guarded release lifecycle** — beta/stable publication now uses explicit promotion, immutable delivery proofs, protected recovery, and tag-bound optional OSS policy; an unprovisioned OSS mirror is sealed as `deferred` so GitHub, npm, and Homebrew are not blocked.
- **Relaxed stable promotion contract** (#729) — a stable release still requires a delivered, non-withdrawn beta baseline in its commit history, but no longer requires a byte-identical tree with that beta; reviewed commits merged to `main` after the beta can now ship in the stable release. Local releases now accept any sealed commit contained in `main` history and push only the release tag, so `main` is never frozen during the beta-to-stable window.
### Fixed
- **Authentication and credential reliability** — organization-policy denials stop before mutation or polling, long-running clients reload and refresh access tokens consistently, concurrent credential writes are atomic, and Windows portable-auth commands fail before reading or writing unsupported credential bundles.
- **Command validation and compatibility** — invalid Sheet/task targets fail locally, IM shortcuts preserve AI-tag and alias compatibility, and Aitable import uploads require and forward a positive file size.
- **Release publication reliability** — GitHub draft publication is bound to one verified release ID and exact assets, preflight uses isolated installer worktrees, guarded local tags remain compatible, cloud planning fingerprints the actual allocated release refs, and npm channel verification waits for bounded registry propagation without moving tags.
- **Package-manager version verification** (#735) — npm-vendored, Homebrew-installed, and packaged release binaries are now verified by searching their raw bytes for the injected version marker, so a correctly versioned stable binary is no longer rejected when the short version marker coalesces with adjacent printable linker metadata; incorrect or missing markers still fail closed.
## [1.0.53-beta.7] - 2026-07-21
This beta validates bounded npm channel verification after registry publication.
### Fixed
- **npm dist-tag eventual consistency** — Release delivery now tolerates a briefly stale `latest` or `beta` read after publishing by retrying only when npm reports a valid older version. Registry errors, invalid or incomparable tags, and channels that never converge still fail closed without moving any tag during verification.
## [1.0.53-beta.6] - 2026-07-21
This beta validates guarded local release compatibility and tag-bound OSS deferral so an unprovisioned mirror cannot block the primary release channels.
@@ -17,6 +185,7 @@ This beta validates guarded local release compatibility and tag-bound OSS deferr
### Fixed
- **Guarded local release compatibility** — The tag-push Release workflow now accepts the `Channel`-only annotated tags created by the guarded local release entry while continuing to reject any partial cloud-only seal metadata.
- **Cloud release tag allocation fingerprint** — Release planning now fingerprints the actual `v*` and `withdrawn/v*` refs fetched from GitHub, matching the seal job's API view instead of hashing an empty non-wildcard ref prefix and rejecting every publish before tag creation.
## [1.0.53-beta.5] - 2026-07-21
+46 -8
View File
@@ -27,7 +27,9 @@ notes that are intentionally kept out of the repository root.
## Local Checks
Run the verification commands that match the surface you changed before you hand work back.
Run the verification commands that match the surface you changed before you
hand work back. The goal is useful, change-specific evidence, not a second
local execution of every CI job.
Common repository checks already used here include:
@@ -44,19 +46,55 @@ make lint
git diff --check
```
Select the PR risk tier before choosing checks:
| Tier | Typical scope | Developer evidence | CI expansion |
|---|---|---|---|
| Documentation-only | Prose and documentation assets with no executable, generated, workflow, packaging, or interface change | Links/content/rendering plus repository asset checks | Lightweight documentation validation; all nine named contexts still report |
| Standard | Ordinary implementation work with a stable package graph | Focused unit/integration tests and observable behavior for the changed path | Race tests for changed packages and their reverse dependencies, scope-matched HEAD/base coverage, and representative Darwin/Windows compilation |
| High-risk | Workflow/policy, package graph, generated Schema/registry, platform, auth/keychain, installer, packaging, release, transport, recovery, or an unprovable infrastructure change | Relevant full or domain suite plus focused behavior evidence | Complete race suite, native platform tests, and all affected domain gates; protected `main` uses this tier |
Classification fails closed: an incomplete diff, package add/remove/rename, or
uncertain dependency graph selects the high-risk suite. Native changed-code
coverage is additionally selected for platform-sensitive code.
## Pull Request Checklist
1. Keep implementation and tests in sync.
2. Run `./scripts/dev/ci-local.sh`.
3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change. CI also runs `./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>` against both the target branch and latest stable release.
4. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may change.
5. Run `./scripts/release/verify-package-managers.sh` when packaging or installer surfaces change (run `make package` first).
6. Update docs and `CHANGELOG.md` for behavior/interface changes.
7. Include verification evidence in your PR description.
2. Select the documentation-only, standard, or high-risk tier and run the
smallest checks that prove the change. Use `./scripts/dev/ci-local.sh` when
a complete local pass is warranted; it is not required for every ordinary
PR.
3. Include both the commands/results and user-visible or contract-level
behavior evidence in the PR description.
4. Run `./scripts/policy/check-command-surface.sh --strict` when command
paths/flags change. CI also runs
`./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>`
against both the target branch and latest stable release.
5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may
change.
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
installer surfaces change (run `make package` first).
7. Update docs and `CHANGELOG.md` for behavior/interface changes.
## Submission Flow
1. Make the smallest atomic change that satisfies the task.
2. Keep doc edits factual and limited to implemented behavior.
3. Run the relevant verification commands.
4. Report the validation results with the handoff.
4. Report the validation results and risk tier with the handoff.
5. Open a ready PR against `main`. Base-owned automation assigns one eligible
peer reviewer, balancing the current open-review load and excluding the
author. A new head push re-enters the same routing flow when the latest
revision still needs review.
6. After the latest push has one peer approval and the exact nine required
contexts are current and green, auto-merge completes the PR. If `main`
advances first, strict status checks revalidate the branch; no separate
routine merge request is needed.
Contributors without repository write access stop at the PR flow. Explicitly
authorized collaborators with `write`, `maintain`, or `admin` access can use
[Actions → Release](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/release.yml)
to publish beta releases without manual approval. The same internal roles may
start a stable release, but a different repository administrator must approve
the `release-stable` Environment deployment before publication continues.
+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.53-beta.4"
version "1.0.55-beta.5"
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.53-beta.4/dws-darwin-arm64.tar.gz"
sha256 "32a442d5b42dfed8512a695a7cef513722db6912f3c3168954cfaefb68e0b075"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-darwin-arm64.tar.gz"
sha256 "ac826a88062c6b839808eb28312dc026cc76bfc298cdedec34c468b87d5c22d6"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-darwin-amd64.tar.gz"
sha256 "00c694677b9ce2e1a711535740681defe0a5f83d6b72140f305483501628faf8"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-darwin-amd64.tar.gz"
sha256 "361faab5cae2299fa2d8d305fd12dde7a992d408cfd5b1ad54ecd99073cd0a45"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-linux-arm64.tar.gz"
sha256 "98516620e861e516cf846cf418f1a0ad5c5eb9a8681bd066b1fd29f4847aca6a"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-linux-arm64.tar.gz"
sha256 "a8d0d39f037d6cb73aae3e4f7432fc58d3430971ce25d74c3c78580377d4dade"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-linux-amd64.tar.gz"
sha256 "266df80e8a989971789a157dd134685a7c9eda01dd1d082719ec9e09d5a07bf0"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-linux-amd64.tar.gz"
sha256 "4a0fc75b9b81f2d8670b9f71bace510515963c4285e9ec62c21aacb3c645c939"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-skills.zip"
sha256 "6770511ab9b04b4d97da1858069ff694830ba91d47c1e8564fd85856f95b016e"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-skills.zip"
sha256 "7120a49c8bac90ea4c77668115b11edeca843e7fef0cc0ff2de538635a9884e1"
end
def install
+13 -11
View File
@@ -1,32 +1,33 @@
class DingtalkWorkspaceCli < Formula
desc "Automate DingTalk workspace tasks from the terminal"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.52"
version "1.0.54"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-darwin-arm64.tar.gz"
sha256 "4f6b4d064a76bcefac42feb5f356253fe43f9499b8cec9d2cdf202e7d3b9b60c"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-darwin-arm64.tar.gz"
sha256 "8ae0e52cf973f6fb3df61c67a41fd11e2df417a0c815762b6060cbcb5e600c08"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-darwin-amd64.tar.gz"
sha256 "abc87128f4b98d0a01ea99235449031971db8fa4ce94167403e3b736c4b81e9a"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-darwin-amd64.tar.gz"
sha256 "11b711b9d70dea62304bf5f8206c56b4e7ea91148dafe97fb7c0f844a2a61da3"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-arm64.tar.gz"
sha256 "0d357ef0535f99f2f63b5ecbfdee9c32448be2a2c24f3096c03126b3b7570bc5"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-linux-arm64.tar.gz"
sha256 "9c7ecb4c8cd55644b2faa73f6ce7843c0279b23793e23deb5061692ea71a0cf1"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-amd64.tar.gz"
sha256 "b7dfd9a4b3489211359261747ed0cb9c8c261434bb762ad3f76df33bdbabd5cb"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-linux-amd64.tar.gz"
sha256 "8a0bc245747fc3facf98c8103c06da46852a30bff31ac93b0aa874e8c7e46db7"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-skills.zip"
sha256 "0fa3c8dec500c1659e6480d6772ae901b2d12d24322dd5d7283f016024290c21"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-skills.zip"
sha256 "7450fd0115c75bfe6820c7099f348973d9353cca9d8d647c9cddcd70978a7ec0"
end
def install
@@ -52,6 +53,7 @@ class DingtalkWorkspaceCli < Formula
<<~EOS
Agent Skills are bundled in #{pkgshare}/skills/dws.
Run `dws skill setup` to install them into your Agent directories.
EOS
end
+19 -10
View File
@@ -8,7 +8,7 @@ POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
.PHONY: all help build rebuild test test-plan lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -17,6 +17,7 @@ help:
@printf " make build - Build the dws CLI binary\n"
@printf " make test - Run the Go test suite\n"
@printf " make test-plan - Verify every default Go package belongs to one CI test shard\n"
@printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n"
@printf " make lint - Run formatting checks, go vet, and staticcheck\n"
@printf " make format-check - Check all repository Go source files with gofmt\n"
@printf " make fmt - Format all repository Go source files\n"
@@ -38,8 +39,8 @@ help:
@printf " make package - Build all release artifacts locally\n"
@printf " make changelog-pre VERSION=vX.Y.Z-beta.N - Prepare prerelease notes\n"
@printf " make changelog-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Prepare stable notes\n"
@printf " make release-pre VERSION=vX.Y.Z-beta.N [PUBLISH=1] - Validate or publish prerelease\n"
@printf " make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N [PUBLISH=1] - Validate or publish stable\n"
@printf " make release-pre VERSION=vX.Y.Z-beta.N - Validate prerelease; publish official releases from Actions\n"
@printf " make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Validate stable; publish official releases from Actions\n"
@printf " make publish-homebrew-formula - Push dist/homebrew/dingtalk-workspace-cli.rb to a tap repo\n"
build:
@@ -54,6 +55,10 @@ test:
test-plan:
@./scripts/ci/test-packages.sh verify
test-auth-legacy-compat:
@mkdir -p "$(POLICY_GOTMPDIR)"
@GO="$(GO)" $(POLICY_ENV) ./scripts/policy/check-auth-legacy-compat.sh
lint:
@./scripts/dev/lint.sh
@@ -76,7 +81,7 @@ fmt:
$(GO_SOURCE_LIST) > "$$go_files"; \
xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files"
policy:
policy: test-auth-legacy-compat
@mkdir -p "$(POLICY_GOTMPDIR)"
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
@$(POLICY_ENV) ./scripts/policy/check-schema-command-registry.sh
@@ -124,16 +129,16 @@ test-schema-agent-examples:
generate-schema:
@set -e; \
registry_guard=$$(mktemp); \
registry_guard=$$(mktemp -d); \
metadata_guard=$$(mktemp -d); \
selection_guard=$$(mktemp -d); \
trap 'rm -rf "$$registry_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \
cp internal/cli/schema_command_registry.json "$$registry_guard"; \
cp -R internal/cli/schema_command_registry/ "$$registry_guard/"; \
cp -R internal/cli/schema_hints/metadata/. "$$metadata_guard/"; \
cp -R internal/cli/schema_hints/selection/. "$$selection_guard/"; \
$(GO) generate ./internal/cli; \
cmp -s internal/cli/schema_command_registry.json "$$registry_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry.json' >&2; \
diff -qr internal/cli/schema_command_registry "$$registry_guard" >/dev/null || { \
printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry/' >&2; \
exit 1; \
}; \
diff -qr internal/cli/schema_hints/metadata "$$metadata_guard" >/dev/null || { \
@@ -148,14 +153,18 @@ generate-schema:
generate-schema-agent-metadata:
$(GO) run ./internal/generator/cmd_schema_agent_metadata \
-root . \
-registry internal/cli/schema_command_registry.json \
-registry internal/cli/schema_command_registry \
-output-dir internal/cli/schema_agent_metadata \
-audit-output internal/cli/schema_agent_metadata_audit.json
generate-schema-catalog:
$(GO) run -a ./internal/generator/cmd_schema_catalog \
-root . \
-output internal/cli/schema_catalog.json
-output internal/cli/schema_catalog
fetch-mcp-metadata:
@printf ' %sRefreshing MCP metadata from live server%s\n' "$(COLOR_RUN)" "$(COLOR_RESET)"
@./scripts/dev/fetch_mcp_metadata.sh
package:
@version="$(if $(VERSION),$(VERSION),v0.0.0-SNAPSHOT)"; VERSION="$${version#v}" ./scripts/dev/build-all.sh
+28 -6
View File
@@ -474,7 +474,9 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
<details>
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog currently covers messages that mention the current user, one-to-one messages with a specified user, and messages in a specified group.
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, and group title/disband lifecycle events.
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
> **Prerequisite**: run `dws auth login`. Personal identity is resolved from the OAuth token and cannot be supplied through command-line identity flags.
@@ -487,19 +489,38 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
```bash
# Inspect the public personal event catalog and schema
dws event list
dws event schema user_im_message_receive_o2o
dws event schema user_im_message_receive_o2o --flatten
# Listen for messages that mention the current user
dws event consume user_im_message_receive_at -f ndjson
dws event consume user_im_message_receive_at --flatten -f ndjson
# Listen for one-to-one messages with a specified user
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
# Listen by openDingtalkId (external contact, bot, or cross-organization identity)
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> -f ndjson
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
# Listen for messages in a specified group
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
# Listen for all one-to-one or all group messages
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
dws event consume user_im_message_receive_group_all --flatten -f ndjson
# Listen for a specified group's title changes, member changes, or disband event
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_member_added --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
# Listen for multiple events for the same user in one process
dws event consume \
user_im_message_receive_o2o \
user_im_message_read_o2o \
user_im_message_recall_o2o \
--user <userId> \
--flatten \
-f ndjson
# Inspect local consumers and cancel a subscription
dws event status
@@ -512,6 +533,7 @@ For one-to-one and specified-sender events, use exactly one target identity: `--
|---------|---------|
| Managed lifecycle | `consume` creates or reuses the personal subscription; `stop` cancels it and cleans local state |
| Shared connection | Consumers for the same user share one local bus and cloud connection |
| Multi-event process | One consume process can listen for compatible events for the same target while retaining one subscription per event |
| Subscription isolation | Normal consumers match both event type and `subscribe_id` |
| Agent-friendly output | Stream events are written to stdout as NDJSON; status and diagnostics use stderr |
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
+28 -6
View File
@@ -468,7 +468,9 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
<details>
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录包括:当前用户被 @ 的消息、与指定用户的单聊消息、指定群的消息。
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应,以及群标题变更和群解散事件。
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
> **前置条件**:先运行 `dws auth login`。个人身份从 OAuth token 解析,不允许通过命令行伪造。
@@ -481,19 +483,38 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
```bash
# 查看公开个人事件目录和 schema
dws event list
dws event schema user_im_message_receive_o2o
dws event schema user_im_message_receive_o2o --flatten
# 监听当前用户被 @ 的消息
dws event consume user_im_message_receive_at -f ndjson
dws event consume user_im_message_receive_at --flatten -f ndjson
# 监听与指定用户的单聊消息
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> -f ndjson
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
# 监听指定群的消息
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
# 监听所有单聊或所有群消息
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
dws event consume user_im_message_receive_group_all --flatten -f ndjson
# 监听指定群标题变更、成员进退群或群解散
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_member_added --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
# 一个进程监听同一用户的多个事件
dws event consume \
user_im_message_receive_o2o \
user_im_message_read_o2o \
user_im_message_recall_o2o \
--user <userId> \
--flatten \
-f ndjson
# 查看本地 consume,并取消指定订阅
dws event status
@@ -506,6 +527,7 @@ dws event stop <subscribe_id>
|------|------|
| 自动编排 | `consume` 创建或复用个人订阅,`stop` 取消订阅并清理本地状态 |
| 共享连接 | 同一用户的多个 consumer 共享本地 bus 和云端长连接 |
| 多事件进程 | 同一目标的兼容事件可由一个 consume 进程监听,每个事件仍有独立订阅 |
| 订阅隔离 | 正常 consumer 同时按事件类型和 `subscribe_id` 匹配 |
| Agent 友好输出 | Stream 事件写入 stdout,连接状态和诊断信息写入 stderr |
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
+403
View File
@@ -0,0 +1,403 @@
// Command fetch_mcp_metadata pulls tools/list from ALL live MCP server endpoints
// and writes a refreshed schema_mcp_metadata.json. This is DWS's equivalent of
// lark-cli's scripts/fetch_meta.py.
//
// Usage:
//
// dws auth login # ensure valid auth
// make fetch-mcp-metadata # runs this tool
//
// The tool loads auth from the DWS keychain, iterates all 26 static server
// endpoints (internal/syncdata.StaticServers), calls tools/list on each,
// merges results, and writes schema_mcp_metadata.json.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"sort"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
// toolLister is the tools/list capability consumed by run; production code
// uses transport.Client, tests inject fakes.
type toolLister interface {
ListTools(ctx context.Context, endpoint string) (transport.ToolsListResult, error)
}
// Injection points so run() is fully testable without network/keychain/exit.
var (
osExit = os.Exit
getenv = os.Getenv
loadTokenData = auth.LoadTokenDataKeychain
staticServers = syncdata.StaticServers
registrySource = cli.EmbeddedCommandRegistryMergedJSON
listToolsTimeout = 30 * time.Second
gitHeadPath = ".git/HEAD"
newToolLister = func(token string) toolLister {
return transport.NewClient(&http.Client{Timeout: 60 * time.Second}).WithAuth(token, nil)
}
)
func main() {
osExit(run(os.Args[1:], os.Stderr))
}
func run(args []string, stderr io.Writer) int {
flags := flag.NewFlagSet("fetch_mcp_metadata", flag.ContinueOnError)
flags.SetOutput(stderr)
output := flags.String("output", "internal/cli/schema_mcp_metadata.json", "output file path")
if err := flags.Parse(args); err != nil {
return 2
}
token := resolveToken(stderr)
if token == "" {
fmt.Fprintln(stderr, "fetch_mcp_metadata: no auth token. Run 'dws auth login' first.")
return 1
}
client := newToolLister(token)
// Iterate ALL static server endpoints (26 servers covering all products).
servers := staticServers()
fmt.Fprintf(stderr, "fetch_mcp_metadata: querying %d server endpoints\n", len(servers))
// Load CLI registry to build tool_name → interface_ref mapping.
registryMap := loadRegistryInterfaceRefs(stderr)
fmt.Fprintf(stderr, "fetch_mcp_metadata: registry mapping: %d entries\n", len(registryMap))
// Load the previous schema_mcp_metadata.json to preserve hand-curated
// cross-server interface_ref mappings that automated matching can't derive.
prevData, prevErr := os.ReadFile(*output)
prevTools := map[string]map[string]any{}
if prevErr == nil {
var prev struct {
Tools map[string]map[string]any `json:"tools"`
}
if json.Unmarshal(prevData, &prev) == nil {
prevTools = prev.Tools
}
}
// Start from previous data (preserves cross-server refs), then overwrite
// with fresh MCP data where available.
allTools := make(map[string]map[string]any)
for k, v := range prevTools {
allTools[k] = v
}
// Reviewed cross-server interface_refs live only in the previous snapshot
// (the registry stores canonical paths, not MCP identities). Build a
// live-key → canonicals index so those tools get refreshed instead of
// being skipped and frozen at the previous snapshot forever.
crossRefs := buildCrossServerRefs(prevTools, registryMap)
if len(crossRefs) > 0 {
fmt.Fprintf(stderr, "fetch_mcp_metadata: cross-server ref index: %d live keys\n", len(crossRefs))
}
// Canonicals with a reviewed cross-server identity must only be fed by
// that identity; a same-named tool on another server is a coincidence,
// not a data source.
crossOwned := map[string]bool{}
for _, canonicals := range crossRefs {
for _, canonical := range canonicals {
crossOwned[canonical] = true
}
}
totalRaw := 0
failedServices := []string{}
for _, srv := range servers {
endpoint := strings.TrimSpace(srv.Endpoint)
if endpoint == "" {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), listToolsTimeout)
result, err := client.ListTools(ctx, endpoint)
cancel()
if err != nil {
fmt.Fprintf(stderr, " [skip] %s: %v\n", srv.ID, err)
failedServices = append(failedServices, srv.ID)
continue
}
fmt.Fprintf(stderr, " [ok] %s: %d tools\n", srv.ID, len(result.Tools))
totalRaw += len(result.Tools)
for _, tool := range result.Tools {
name := strings.TrimSpace(tool.Name)
if name == "" {
continue
}
// Direct match: CLI canonical equals server-prefixed tool name
// (e.g., "doc.copy_document"). Cross-owned canonicals are skipped
// here — their reviewed identity feeds them below.
canonicalKey := srv.ID + "." + name
if ref, hasRef := registryMap[canonicalKey]; hasRef && !crossOwned[canonicalKey] {
mergeLiveMCPTool(allTools, canonicalKey, tool, ref)
}
// Cross-server match: registry canonicals whose reviewed
// interface_ref points at this live tool (one live tool may feed
// several canonicals, e.g. advperm_enable/disable → set_advanced_permission).
for _, canonical := range crossRefs[canonicalKey] {
mergeLiveMCPTool(allTools, canonical, tool, registryMap[canonical])
}
}
}
matched := 0
for _, t := range allTools {
if _, ok := t["interface_ref"]; ok {
matched++
}
}
fmt.Fprintf(stderr, "fetch_mcp_metadata: MCP matched=%d, with interface_ref=%d\n", len(allTools), matched)
// Fill gaps: for registry canonicals not covered by MCP tools/list OR
// previous data, add stub entries (interface_ref only).
stubs := 0
for canonicalKey, ref := range registryMap {
if _, exists := allTools[canonicalKey]; exists {
continue
}
allTools[canonicalKey] = map[string]any{
"interface_ref": ref,
}
stubs++
}
if stubs > 0 {
fmt.Fprintf(stderr, "fetch_mcp_metadata: added %d registry stubs (no MCP data, interface_ref only)\n", stubs)
}
// Compute coverage fields required by check-schema-catalog.sh. Failed
// services must be reported honestly so policy can spot snapshot gaps.
if len(failedServices) > 0 {
fmt.Fprintf(stderr, "fetch_mcp_metadata: %d/%d services unreachable: %s\n",
len(failedServices), len(servers), strings.Join(failedServices, ", "))
}
metadata := map[string]any{
"version": 1,
"source": "mcp-tools-list+cli-registry",
"coverage": buildCoverage(len(servers), failedServices, totalRaw, len(allTools), stubs),
"tools": allTools,
}
// source_revision: git commit hash (proves provenance).
if rev, err := os.ReadFile(gitHeadPath); err == nil {
metadata["source_revision"] = strings.TrimSpace(string(rev))
}
if err := writeMetadata(*output, metadata); err != nil {
fmt.Fprintf(stderr, "fetch_mcp_metadata: %v\n", err)
return 1
}
fmt.Fprintf(stderr, "fetch_mcp_metadata: wrote %d tools to %s\n", len(allTools), *output)
return 0
}
// resolveToken returns the access token from DWS_ACCESS_TOKEN or, as a
// fallback, the DWS keychain.
func resolveToken(stderr io.Writer) string {
token := strings.TrimSpace(getenv("DWS_ACCESS_TOKEN"))
if token != "" {
return token
}
td, err := loadTokenData()
if err != nil || td == nil || td.AccessToken == "" {
return ""
}
fmt.Fprintf(stderr, "fetch_mcp_metadata: loaded token from keychain (%d chars)\n", len(td.AccessToken))
return td.AccessToken
}
// writeMetadata marshals the snapshot and writes it to the output path.
func writeMetadata(path string, metadata map[string]any) error {
data, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return fmt.Errorf("marshal failed: %w", err)
}
data = append(data, '\n')
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("write %s failed: %w", path, err)
}
return nil
}
// buildCoverage reports snapshot coverage honestly: snapshot_services only
// counts services whose tools/list succeeded, missing_services names the
// failures, and matched_tools excludes registry stubs (entries carrying no
// live MCP metadata) so a stub-heavy snapshot cannot claim full matching.
func buildCoverage(sourceServices int, failedServices []string, sourceTools, surfaceTools, stubs int) map[string]any {
missing := failedServices
if missing == nil {
missing = []string{}
}
return map[string]any{
"surface_scope": "source_revision",
"source_services": sourceServices,
"snapshot_services": sourceServices - len(missing),
"missing_services": missing,
"source_tools": sourceTools,
"surface_tools": surfaceTools,
"matched_tools": surfaceTools - stubs,
"aliased_tools": 0,
"unmatched_tools": stubs,
}
}
// mergeLiveMCPTool replaces stale live-derived fields while retaining an
// existing reviewed interface_ref. Some CLI canonicals intentionally route to
// a differently named product/RPC, so the previous cross-server mapping must
// survive even though title, description, and parameters are refreshed.
func mergeLiveMCPTool(allTools map[string]map[string]any, canonicalKey string, tool transport.ToolDescriptor, fallbackRef map[string]string) {
interfaceRef := any(fallbackRef)
if previous := allTools[canonicalKey]; previous != nil {
if reviewedRef, ok := previous["interface_ref"]; ok && reviewedRef != nil {
interfaceRef = reviewedRef
}
}
entry := map[string]any{
"title": tool.Title,
"description": tool.Description,
"interface_ref": interfaceRef,
}
if tool.InputSchema != nil {
entry["parameters"] = extractParams(tool.InputSchema)
}
allTools[canonicalKey] = entry
}
// buildCrossServerRefs indexes reviewed cross-server mappings from the
// previous snapshot: for every registry canonical whose interface_ref names a
// different MCP identity (product_id.rpc_name != canonical), the live key is
// mapped back to that canonical. One live tool may serve several canonicals,
// so values are slices, sorted for deterministic merge order.
func buildCrossServerRefs(prevTools map[string]map[string]any, registryMap map[string]map[string]string) map[string][]string {
index := map[string][]string{}
for canonical, entry := range prevTools {
if _, inRegistry := registryMap[canonical]; !inRegistry {
continue
}
ref, ok := entry["interface_ref"].(map[string]any)
if !ok {
continue
}
productID, _ := ref["product_id"].(string)
rpcName, _ := ref["rpc_name"].(string)
if productID == "" || rpcName == "" {
continue
}
liveKey := productID + "." + rpcName
if liveKey == canonical {
continue
}
index[liveKey] = append(index[liveKey], canonical)
}
for _, canonicals := range index {
sort.Strings(canonicals)
}
return index
}
// loadRegistryInterfaceRefs loads the reviewed split CommandRegistry through
// the cli package's reassembly API and builds a canonical_path →
// {product_id, rpc_name} mapping for interface_ref injection.
func loadRegistryInterfaceRefs(stderr io.Writer) map[string]map[string]string {
data, err := registrySource()
if err != nil {
fmt.Fprintf(stderr, "fetch_mcp_metadata: warning: cannot load registry: %v\n", err)
return map[string]map[string]string{}
}
var reg struct {
Products []struct {
ID string `json:"id"`
Tools []struct {
CanonicalPath string `json:"canonical_path"`
} `json:"tools"`
} `json:"products"`
}
if err := json.Unmarshal(data, &reg); err != nil {
// 与读文件失败同等告警:静默返回空映射会让所有 live tool 被丢弃、
// 产出 stub-only 快照且零提示(P1#1 的故障模式)。
fmt.Fprintf(stderr, "fetch_mcp_metadata: warning: cannot parse registry: %v\n", err)
return map[string]map[string]string{}
}
out := make(map[string]map[string]string)
for _, prod := range reg.Products {
for _, tool := range prod.Tools {
cp := strings.TrimSpace(tool.CanonicalPath)
if cp == "" || !strings.Contains(cp, ".") {
continue
}
parts := strings.SplitN(cp, ".", 2)
out[cp] = map[string]string{
"product_id": parts[0],
"rpc_name": parts[1],
}
}
}
return out
}
// extractParams converts a JSON Schema inputSchema (from MCP tools/list) into
// the flat param-name → metadata map used by schema_mcp_metadata.json.
func extractParams(inputSchema map[string]any) map[string]map[string]any {
if inputSchema == nil {
return nil
}
properties, ok := inputSchema["properties"].(map[string]any)
if !ok {
return nil
}
requiredSet := map[string]bool{}
if req, ok := inputSchema["required"].([]any); ok {
for _, r := range req {
if s, ok := r.(string); ok {
requiredSet[s] = true
}
}
}
params := make(map[string]map[string]any, len(properties))
for name, raw := range properties {
prop, ok := raw.(map[string]any)
if !ok {
continue
}
meta := map[string]any{}
if t, ok := prop["type"].(string); ok {
meta["type"] = t
}
if d, ok := prop["description"].(string); ok {
meta["description"] = d
}
if d, ok := prop["default"].(string); ok {
meta["default"] = d
}
if e, ok := prop["enum"].([]any); ok {
enums := make([]string, 0, len(e))
for _, v := range e {
if s, ok := v.(string); ok {
enums = append(enums, s)
}
}
if len(enums) > 0 {
meta["enum"] = enums
}
}
meta["required"] = requiredSet[name]
params[name] = meta
}
return params
}
+557
View File
@@ -0,0 +1,557 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"math"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func TestLoadRegistryInterfaceRefsUsesSplitRegistry(t *testing.T) {
var stderr bytes.Buffer
refs := loadRegistryInterfaceRefs(&stderr)
if len(refs) == 0 {
t.Fatal("loadRegistryInterfaceRefs() returned no reviewed commands")
}
got, ok := refs["calendar.list_calendars"]
if !ok {
t.Fatal("calendar.list_calendars missing from reassembled split registry")
}
if got["product_id"] != "calendar" || got["rpc_name"] != "list_calendars" {
t.Fatalf("calendar.list_calendars ref = %#v", got)
}
}
func TestBuildCrossServerRefs(t *testing.T) {
registryMap := map[string]map[string]string{
"aitable.advperm_enable": {"product_id": "aitable", "rpc_name": "advperm_enable"},
"aitable.advperm_disable": {"product_id": "aitable", "rpc_name": "advperm_disable"},
"doc.copy_document": {"product_id": "doc", "rpc_name": "copy_document"},
}
prevTools := map[string]map[string]any{
// Fan-out: two canonicals share one live tool; insertion order must
// not affect the sorted result.
"aitable.advperm_enable": {
"interface_ref": map[string]any{"product_id": "aitable-helper", "rpc_name": "set_advanced_permission"},
},
"aitable.advperm_disable": {
"interface_ref": map[string]any{"product_id": "aitable-helper", "rpc_name": "set_advanced_permission"},
},
// Identity ref (live key == canonical) needs no cross entry.
"doc.copy_document": {
"interface_ref": map[string]any{"product_id": "doc", "rpc_name": "copy_document"},
},
// Not in the registry: must be ignored.
"ghost.tool": {
"interface_ref": map[string]any{"product_id": "ghost-helper", "rpc_name": "haunt"},
},
}
got := buildCrossServerRefs(prevTools, registryMap)
want := map[string][]string{
"aitable-helper.set_advanced_permission": {"aitable.advperm_disable", "aitable.advperm_enable"},
}
if len(got) != len(want) {
t.Fatalf("index = %#v, want %#v", got, want)
}
for k, v := range want {
if gv := got[k]; len(gv) != len(v) || gv[0] != v[0] || gv[1] != v[1] {
t.Fatalf("index[%q] = %v, want %v", k, gv, v)
}
}
}
func TestBuildCrossServerRefsSkipsMalformedRefs(t *testing.T) {
registryMap := map[string]map[string]string{
"a.x": {"product_id": "a", "rpc_name": "x"},
"a.y": {"product_id": "a", "rpc_name": "y"},
"a.z": {"product_id": "a", "rpc_name": "z"},
}
prevTools := map[string]map[string]any{
"a.x": {"interface_ref": "not-a-map"},
"a.y": {"interface_ref": map[string]any{"product_id": "", "rpc_name": "r"}},
"a.z": {"title": "no ref at all"},
}
if got := buildCrossServerRefs(prevTools, registryMap); len(got) != 0 {
t.Fatalf("index = %#v, want empty", got)
}
}
func TestRunRefreshesCrossServerTools(t *testing.T) {
registry := func() ([]byte, error) {
return []byte(`{"version":1,"products":[{"id":"aitable","tools":[{"canonical_path":"aitable.advperm_enable"},{"canonical_path":"aitable.advperm_disable"}]}]}`), nil
}
servers := []syncdata.ServerInfo{{ID: "aitable-helper", Endpoint: "https://helper.example"}}
lister := &fakeLister{
results: map[string]transport.ToolsListResult{
"https://helper.example": {Tools: []transport.ToolDescriptor{
{Name: "set_advanced_permission", Title: "live title", Description: "live desc"},
}},
},
}
stubDeps(t, "env-token", nil, servers, lister, registry)
output := filepath.Join(t.TempDir(), "snapshot.json")
prev := `{"tools":{
"aitable.advperm_enable":{"title":"stale","interface_ref":{"product_id":"aitable-helper","rpc_name":"set_advanced_permission"}},
"aitable.advperm_disable":{"title":"stale","interface_ref":{"product_id":"aitable-helper","rpc_name":"set_advanced_permission"}}
}}`
if err := os.WriteFile(output, []byte(prev), 0o600); err != nil {
t.Fatal(err)
}
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
if !strings.Contains(stderr.String(), "cross-server ref index: 1 live keys") {
t.Fatalf("stderr = %q, want cross-server index log", stderr.String())
}
data, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
var snapshot struct {
Tools map[string]map[string]any `json:"tools"`
}
if err := json.Unmarshal(data, &snapshot); err != nil {
t.Fatal(err)
}
for _, canonical := range []string{"aitable.advperm_enable", "aitable.advperm_disable"} {
entry := snapshot.Tools[canonical]
if entry["title"] != "live title" || entry["description"] != "live desc" {
t.Fatalf("%s = %#v, want live refresh", canonical, entry)
}
ref := entry["interface_ref"].(map[string]any)
if ref["product_id"] != "aitable-helper" || ref["rpc_name"] != "set_advanced_permission" {
t.Fatalf("%s reviewed ref lost: %#v", canonical, ref)
}
}
}
// TestRunCrossOwnedCanonicalIgnoresNameCoincidence:canonical 拥有评审过的
// 跨 server 身份时,另一 server 上恰好同名的工具不得直连覆盖其元数据——
// 数据源只能是评审身份指向的 live 工具。
func TestRunCrossOwnedCanonicalIgnoresNameCoincidence(t *testing.T) {
registry := func() ([]byte, error) {
return []byte(`{"version":1,"products":[{"id":"aitable","tools":[{"canonical_path":"aitable.advperm_enable"}]}]}`), nil
}
servers := []syncdata.ServerInfo{
{ID: "aitable", Endpoint: "https://aitable.example"},
{ID: "aitable-helper", Endpoint: "https://helper.example"},
}
lister := &fakeLister{
results: map[string]transport.ToolsListResult{
// 同名巧合:aitable server 上恰好也有 advperm_enable。
"https://aitable.example": {Tools: []transport.ToolDescriptor{
{Name: "advperm_enable", Title: "coincidence title", Description: "coincidence desc"},
}},
"https://helper.example": {Tools: []transport.ToolDescriptor{
{Name: "set_advanced_permission", Title: "owner title", Description: "owner desc"},
}},
},
}
stubDeps(t, "env-token", nil, servers, lister, registry)
output := filepath.Join(t.TempDir(), "snapshot.json")
prev := `{"tools":{"aitable.advperm_enable":{"title":"stale","interface_ref":{"product_id":"aitable-helper","rpc_name":"set_advanced_permission"}}}}`
if err := os.WriteFile(output, []byte(prev), 0o600); err != nil {
t.Fatal(err)
}
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
data, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
var snapshot struct {
Tools map[string]map[string]any `json:"tools"`
}
if err := json.Unmarshal(data, &snapshot); err != nil {
t.Fatal(err)
}
entry := snapshot.Tools["aitable.advperm_enable"]
if entry["title"] != "owner title" || entry["description"] != "owner desc" {
t.Fatalf("entry = %#v, want reviewed-identity source to win over name coincidence", entry)
}
}
func TestMergeLiveMCPToolRefreshesExistingMetadata(t *testing.T) {
const canonical = "calendar.list_calendars"
reviewedRef := map[string]any{
"product_id": "calendar-helper",
"rpc_name": "list_user_calendars",
}
allTools := map[string]map[string]any{
canonical: {
"title": "old title",
"description": "old description",
"interface_ref": reviewedRef,
"parameters": map[string]any{
"stale": map[string]any{"type": "string"},
},
},
}
live := transport.ToolDescriptor{
Name: "list_calendars",
Title: "new title",
Description: "new description",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"cursor": map[string]any{
"type": "string",
"description": "next page cursor",
},
},
"required": []any{"cursor"},
},
}
fallbackRef := map[string]string{
"product_id": "calendar",
"rpc_name": "list_calendars",
}
mergeLiveMCPTool(allTools, canonical, live, fallbackRef)
got := allTools[canonical]
if got["title"] != "new title" || got["description"] != "new description" {
t.Fatalf("live metadata was not refreshed: %#v", got)
}
if !reflect.DeepEqual(got["interface_ref"], reviewedRef) {
t.Fatalf("interface_ref = %#v, want reviewed mapping %#v", got["interface_ref"], reviewedRef)
}
params, ok := got["parameters"].(map[string]map[string]any)
if !ok {
t.Fatalf("parameters type = %T, want refreshed parameter map", got["parameters"])
}
if _, stale := params["stale"]; stale {
t.Fatalf("stale parameter survived refresh: %#v", params)
}
if cursor := params["cursor"]; cursor["type"] != "string" || cursor["description"] != "next page cursor" || cursor["required"] != true {
t.Fatalf("cursor parameter = %#v", cursor)
}
}
func TestBuildCoverageReportsFailedServices(t *testing.T) {
got := buildCoverage(26, []string{"doc", "sheet"}, 800, 813, 40)
if got["source_services"] != 26 {
t.Fatalf("source_services = %v, want 26", got["source_services"])
}
if got["snapshot_services"] != 24 {
t.Fatalf("snapshot_services = %v, want 24 (26 sources - 2 failures)", got["snapshot_services"])
}
if !reflect.DeepEqual(got["missing_services"], []string{"doc", "sheet"}) {
t.Fatalf("missing_services = %#v, want failed service IDs", got["missing_services"])
}
// matched 必须剔除 stub 占位,unmatched 据实等于 stub 数。
if got["matched_tools"] != 773 || got["unmatched_tools"] != 40 {
t.Fatalf("matched/unmatched = %v/%v, want 773/40 (813 surface - 40 stubs)", got["matched_tools"], got["unmatched_tools"])
}
if got["source_tools"] != 800 || got["surface_tools"] != 813 {
t.Fatalf("tool counts = %#v", got)
}
}
func TestBuildCoverageFullSnapshotHasNoMissingServices(t *testing.T) {
got := buildCoverage(26, nil, 813, 813, 0)
if got["snapshot_services"] != 26 {
t.Fatalf("snapshot_services = %v, want 26", got["snapshot_services"])
}
if !reflect.DeepEqual(got["missing_services"], []string{}) {
t.Fatalf("missing_services = %#v, want empty non-nil slice", got["missing_services"])
}
if got["matched_tools"] != 813 || got["unmatched_tools"] != 0 {
t.Fatalf("matched/unmatched = %v/%v, want 813/0 for stub-free snapshot", got["matched_tools"], got["unmatched_tools"])
}
}
// fakeLister returns canned tools/list results per endpoint.
type fakeLister struct {
results map[string]transport.ToolsListResult
errs map[string]error
}
func (f *fakeLister) ListTools(_ context.Context, endpoint string) (transport.ToolsListResult, error) {
if err := f.errs[endpoint]; err != nil {
return transport.ToolsListResult{}, err
}
return f.results[endpoint], nil
}
// stubDeps swaps every injection point for the duration of one test.
func stubDeps(t *testing.T, token string, keychain func() (*auth.TokenData, error), servers []syncdata.ServerInfo, lister toolLister, registry func() ([]byte, error)) {
t.Helper()
origGetenv, origLoad, origServers, origNew, origRegistry := getenv, loadTokenData, staticServers, newToolLister, registrySource
t.Cleanup(func() {
getenv, loadTokenData, staticServers, newToolLister, registrySource = origGetenv, origLoad, origServers, origNew, origRegistry
})
getenv = func(key string) string {
if key == "DWS_ACCESS_TOKEN" {
return token
}
return ""
}
loadTokenData = keychain
staticServers = func() []syncdata.ServerInfo { return servers }
newToolLister = func(string) toolLister { return lister }
registrySource = registry
}
func testRegistryJSON() ([]byte, error) {
return []byte(`{"version":1,"products":[{"id":"doc","tools":[{"canonical_path":"doc.copy_document"},{"canonical_path":"doc.get_document"},{"canonical_path":"bad-entry"}]}]}`), nil
}
func TestRunNoTokenFails(t *testing.T) {
stubDeps(t, "", func() (*auth.TokenData, error) { return nil, errors.New("no keychain") }, nil, &fakeLister{}, testRegistryJSON)
var stderr bytes.Buffer
if code := run(nil, &stderr); code != 1 {
t.Fatalf("run() = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "no auth token") {
t.Fatalf("stderr = %q, want no-auth-token hint", stderr.String())
}
}
func TestRunInvalidFlagFails(t *testing.T) {
stubDeps(t, "tok", nil, nil, &fakeLister{}, testRegistryJSON)
var stderr bytes.Buffer
if code := run([]string{"--nonexistent"}, &stderr); code != 2 {
t.Fatalf("run() = %d, want 2", code)
}
}
func TestResolveTokenKeychainFallback(t *testing.T) {
stubDeps(t, "", func() (*auth.TokenData, error) {
return &auth.TokenData{AccessToken: "kc-token"}, nil
}, nil, &fakeLister{}, testRegistryJSON)
var stderr bytes.Buffer
if got := resolveToken(&stderr); got != "kc-token" {
t.Fatalf("resolveToken() = %q, want kc-token", got)
}
if !strings.Contains(stderr.String(), "loaded token from keychain") {
t.Fatalf("stderr = %q, want keychain log", stderr.String())
}
}
func TestResolveTokenEmptyKeychainToken(t *testing.T) {
stubDeps(t, "", func() (*auth.TokenData, error) { return &auth.TokenData{}, nil }, nil, &fakeLister{}, testRegistryJSON)
var stderr bytes.Buffer
if got := resolveToken(&stderr); got != "" {
t.Fatalf("resolveToken() = %q, want empty", got)
}
}
func TestRunWritesSnapshotWithHonestCoverage(t *testing.T) {
servers := []syncdata.ServerInfo{
{ID: "doc", Endpoint: "https://doc.example"},
{ID: "sheet", Endpoint: "https://sheet.example"},
{ID: "blank", Endpoint: " "},
}
lister := &fakeLister{
results: map[string]transport.ToolsListResult{
"https://doc.example": {Tools: []transport.ToolDescriptor{
{Name: "copy_document", Title: "复制文档", Description: "copy", InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"doc_id": map[string]any{"type": "string", "description": "文档 ID", "default": "d", "enum": []any{"a", "b", 3}},
"bogus": "not-a-map",
},
"required": []any{"doc_id", 42},
}},
{Name: " "},
{Name: "not_in_registry"},
}},
},
errs: map[string]error{"https://sheet.example": errors.New("boom")},
}
stubDeps(t, "env-token", nil, servers, lister, testRegistryJSON)
dir := t.TempDir()
output := filepath.Join(dir, "snapshot.json")
prev := `{"tools":{"doc.get_document":{"interface_ref":{"product_id":"doc-helper","rpc_name":"fetch_document"}}}}`
if err := os.WriteFile(output, []byte(prev), 0o600); err != nil {
t.Fatal(err)
}
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
data, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
var snapshot struct {
Version int `json:"version"`
Coverage map[string]any `json:"coverage"`
Tools map[string]map[string]any
}
if err := json.Unmarshal(data, &snapshot); err != nil {
t.Fatal(err)
}
if snapshot.Version != 1 {
t.Fatalf("version = %d", snapshot.Version)
}
if got := snapshot.Coverage["snapshot_services"].(float64); got != 2 {
t.Fatalf("snapshot_services = %v, want 2 (3 servers - 1 failed; blank endpoint not counted as failed)", got)
}
if got := snapshot.Coverage["missing_services"].([]any); len(got) != 1 || got[0] != "sheet" {
t.Fatalf("missing_services = %v, want [sheet]", got)
}
live := snapshot.Tools["doc.copy_document"]
if live == nil || live["title"] != "复制文档" {
t.Fatalf("doc.copy_document = %#v, want live metadata", live)
}
params := live["parameters"].(map[string]any)
docID := params["doc_id"].(map[string]any)
if docID["type"] != "string" || docID["required"] != true || docID["default"] != "d" {
t.Fatalf("doc_id = %#v", docID)
}
if enum := docID["enum"].([]any); len(enum) != 2 {
t.Fatalf("enum = %v, want the 2 string members only", enum)
}
if _, ok := params["bogus"]; ok {
t.Fatal("non-map property should be skipped")
}
prevRef := snapshot.Tools["doc.get_document"]["interface_ref"].(map[string]any)
if prevRef["product_id"] != "doc-helper" {
t.Fatalf("previous reviewed ref lost: %#v", prevRef)
}
if _, ok := snapshot.Tools["not_in_registry"]; ok {
t.Fatal("tools outside the registry must be dropped")
}
if !strings.Contains(stderr.String(), "services unreachable: sheet") {
t.Fatalf("stderr = %q, want unreachable log", stderr.String())
}
}
func TestRunIgnoresCorruptPreviousSnapshot(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
output := filepath.Join(t.TempDir(), "snapshot.json")
if err := os.WriteFile(output, []byte("{corrupt"), 0o600); err != nil {
t.Fatal(err)
}
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
}
func TestRunRegistryLoadFailureStillWritesStublessSnapshot(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() ([]byte, error) { return nil, errors.New("no registry") })
output := filepath.Join(t.TempDir(), "snapshot.json")
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
if !strings.Contains(stderr.String(), "cannot load registry") {
t.Fatalf("stderr = %q, want registry warning", stderr.String())
}
}
func TestRunUnparsableRegistryYieldsNoRefs(t *testing.T) {
var stderr bytes.Buffer
stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() ([]byte, error) { return []byte("{bad"), nil })
if refs := loadRegistryInterfaceRefs(&stderr); len(refs) != 0 {
t.Fatalf("refs = %v, want empty for unparsable registry", refs)
}
// 解析失败必须有告警,不得静默产出空映射。
if !strings.Contains(stderr.String(), "cannot parse registry") {
t.Fatalf("stderr = %q, want parse warning", stderr.String())
}
}
func TestRunWriteFailure(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
var stderr bytes.Buffer
badPath := filepath.Join(t.TempDir(), "missing-dir", "snapshot.json")
if code := run([]string{"--output", badPath}, &stderr); code != 1 {
t.Fatalf("run() = %d, want 1 on write failure", code)
}
}
func TestWriteMetadataMarshalFailure(t *testing.T) {
err := writeMetadata(filepath.Join(t.TempDir(), "out.json"), map[string]any{"bad": math.NaN()})
if err == nil || !strings.Contains(err.Error(), "marshal failed") {
t.Fatalf("err = %v, want marshal failure", err)
}
}
func TestMainDelegatesToRun(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
origExit, origArgs := osExit, os.Args
t.Cleanup(func() { osExit, os.Args = origExit, origArgs })
exitCode := -1
osExit = func(code int) { exitCode = code }
os.Args = []string{"fetch_mcp_metadata", "--output", filepath.Join(t.TempDir(), "snapshot.json")}
main()
if exitCode != 0 {
t.Fatalf("main() exited with %d, want 0", exitCode)
}
}
func TestExtractParamsNilAndNonObjectSchemas(t *testing.T) {
if got := extractParams(nil); got != nil {
t.Fatalf("extractParams(nil) = %v, want nil", got)
}
if got := extractParams(map[string]any{"type": "object"}); got != nil {
t.Fatalf("extractParams(no properties) = %v, want nil", got)
}
}
func TestNewToolListerBuildsAuthedClient(t *testing.T) {
if lister := newToolLister("tok"); lister == nil {
t.Fatal("newToolLister returned nil")
}
}
func TestRunRecordsSourceRevision(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
dir := t.TempDir()
head := filepath.Join(dir, "HEAD")
if err := os.WriteFile(head, []byte("ref: refs/heads/feature\n"), 0o600); err != nil {
t.Fatal(err)
}
origHead := gitHeadPath
t.Cleanup(func() { gitHeadPath = origHead })
gitHeadPath = head
output := filepath.Join(dir, "snapshot.json")
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
data, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
var snapshot struct {
SourceRevision string `json:"source_revision"`
}
if err := json.Unmarshal(data, &snapshot); err != nil {
t.Fatal(err)
}
if snapshot.SourceRevision != "ref: refs/heads/feature" {
t.Fatalf("source_revision = %q", snapshot.SourceRevision)
}
}
+20 -7
View File
@@ -52,7 +52,13 @@ run.
```mermaid
flowchart TB
PR["Pull request"] --> CA["CI"]
PR["Pull request"] --> CLASSIFY["Fail-closed risk classification"]
CLASSIFY --> DOCS["Documentation-only<br/>asset/content validation"]
CLASSIFY --> STANDARD["Standard<br/>affected + reverse-dependent race<br/>scope-matched HEAD/base coverage"]
CLASSIFY --> HIGH["High-risk / main<br/>full race + native tests"]
DOCS --> CA["CI"]
STANDARD --> CA
HIGH --> CA
subgraph CA_CHECKS["Nine required contexts"]
L["Lint"]
T["Test"]
@@ -72,9 +78,16 @@ flowchart TB
PLATFORM --> RELEASE
```
Complete Multi-profile E2E and the ordinary full native-platform matrix are
downstream of PR admission. PRs still run primary-environment assurance and
fast cross-platform compilation; auth, keychain, OS-specific, installer, and
release changes additionally select native platform tests before merge. See
[`docs/ci-pr-gates.md`](ci-pr-gates.md) for the exact context and ruleset
contract.
All nine named contexts are produced for every tier. Domain-specific helpers
run when their owned surface is affected; otherwise the corresponding context
records an explicit unaffected success. Standard code changes still receive
representative Darwin/Windows compilation. High-risk PRs and protected `main`
run the complete race and native test suites, while platform-sensitive diffs
also receive native changed-code coverage.
Review orchestration is also base-owned: it requests one eligible peer without
executing PR code, re-routes an updated head when needed, and auto-merge
completes only after the latest push has peer approval plus the current
revision's nine strict contexts. Complete Multi-profile E2E remains downstream
of PR admission. See [`docs/ci-pr-gates.md`](ci-pr-gates.md) for the exact
classification, context, reviewer, and ruleset contract.
+26 -32
View File
@@ -62,32 +62,27 @@ make lint
git diff --check
```
## Homebrew Formula PR Automation
## Homebrew Formula Delivery
Official tag releases require the repository Actions secret
`HOMEBREW_PR_TOKEN`. Prefer a fine-grained personal access token owned by a
maintainer or release-bot account, limited to this repository with
`Contents: write` and `Pull requests: write`. If organization policy prevents
that account from targeting the repository, use a dedicated classic token with
only the `public_repo` scope. Do not reuse a broad developer token.
Official releases use the Release workflow's built-in `GITHUB_TOKEN` to update
exactly one tracked Formula after the immutable GitHub assets and their
checksums have passed verification. The publisher validates the rendered Ruby,
commits only the configured Formula path, never force-pushes `main`, and retries
from a fresh clone up to three times when `main` advances concurrently. Normal
stable and beta releases do not create a Formula PR or run a permission
canary. The workflow uses the existing repository-scoped
`HOMEBREW_PR_TOKEN` release identity because GitHub does not allow its built-in
Actions App to bypass this repository's rulesets. That identity is the sole
user bypass actor on the two default-branch rulesets. The workflow creates the
nine Code Admission checks for the Formula-only commit only after proving its
sole parent already has all nine successful checks and the committed Formula
exactly matches this release's verified bytes.
Store the dedicated token as the `HOMEBREW_PR_TOKEN` repository Actions secret
and rotate it before its configured expiration. Replace it immediately if it is
exposed, its owner loses repository access, or the release-bot ownership
changes. The Release workflow uses this
dedicated token only to push an `automation/homebrew-*` branch and open the
stable or beta Formula PR. It does not push Formula changes directly to `main`.
The default-branch governance preflight and every tag contract authenticate the
token before publication, reject over-scoped classic tokens, confirm its
identity, and run a controlled write canary. The canary pushes a unique
`automation/homebrew-token-canary-*` branch with a `[skip ci]` commit, creates a
draft PR, closes it, and deletes the branch with the same token. This proves both
Contents and Pull requests write access before publication without merging
anything. The gate also rejects reuse of `RELEASE_GOVERNANCE_TOKEN`.
No maintainer environment variable is required when creating a tag. Using the
built-in `GITHUB_TOKEN` is insufficient because organization policy prevents
Actions from creating pull requests, and its generated PR events may require
separate workflow approval.
Keep `HOMEBREW_PR_TOKEN` repository-scoped with `Contents: write` and
`Pull requests: write` (the latter remains necessary for withdrawal rollback),
keep its owner as the designated ruleset bypass actor, and do not reuse
`RELEASE_GOVERNANCE_TOKEN`. The workflow and publisher provide the Formula-only
path restriction; GitHub rulesets do not infer that restriction from the token.
## Release Governance and Recovery
@@ -98,15 +93,14 @@ administration setting and cannot be read by the workflow's built-in
contract use this same credential so a missing or expired identity is detected
before an irreversible tag is created.
Create a protected `release-recovery` environment limited to protected
branches, with a required reviewer, self-review disabled, and administrator
bypass disabled. The workflow reads the environment through the GitHub API and
fails closed unless the required-reviewer, prevent-self-review, and protected-
branch rules are present.
Recovery is restricted to an existing annotated tag whose exact tag object,
commit, and failed tag-push run all match; it then reuses the normal release
jobs. Do not put publication secrets in temporary branches or create ad-hoc
recovery workflows.
commit, sealed metadata, original failed run/attempt, requester identity and
Release state all match; it then reuses the normal release jobs without a
second-person environment approval. A same-run “Re-run failed jobs” is even
lighter: the seal job may adopt an existing tag only when its complete
authority matches that run and its original attempt is not newer than the
current attempt. Do not put publication secrets in temporary branches or
create ad-hoc recovery workflows.
Cloud-sealed releases mirror to OSS only when the repository variable
`ENABLE_OSS_MIRROR` is exactly `true`. Leave the variable unset while no Bucket
+80 -20
View File
@@ -4,9 +4,9 @@ The pull-request admission layer has exactly nine required external contexts:
| Required context | Contract |
|---|---|
| `Lint` | Stable PR revision classification, formatting, `go vet`, and Actionlint |
| `Test` | Race/unit/release-script tests plus fast cross-platform compilation |
| `Coverage` | Overall non-regression and 100% changed-code coverage |
| `Lint` | Stable PR revision/risk classification plus applicable formatting, `go vet`, and Actionlint |
| `Test` | Tier-selected race/unit/release-script tests plus representative cross-platform compilation |
| `Coverage` | Scope-matched overall non-regression and 100% changed-code coverage |
| `Policy` | Repository policy and the fail-closed CHANGELOG contract |
| `Edition` | Edition contract tests |
| `Interface Integrity` | CLI, Schema, Skill, and stable-release compatibility |
@@ -56,8 +56,27 @@ base notes into an invalid final CHANGELOG.
All nine admission contexts are still emitted and must succeed. Expensive
implementation helpers are skipped; the named contexts record that their code
surface is unaffected. After merge, the protected `main` push executes the
full admission suite.
surface is unaffected.
The protected `main` push keeps that fast path only when all of these
fail-closed conditions hold:
- the event is a non-forced update of the existing `refs/heads/main`;
- the event `after` SHA is the exact workflow SHA, and both event SHAs are
complete, non-zero commit IDs;
- GitHub's comparison reports the previous main tip as the unique linear merge
base, with no commits behind it;
- the complete resulting tree diff is exactly one in-place modification of
`CHANGELOG.md`;
- the previous main tip already has successful GitHub Actions checks for all
nine Code Admission contexts.
`Policy` then independently checks out the pushed revision and runs the same
`check-changelog-pr.sh --fast-path` contract from the event's `before` SHA to
its `after` SHA. If identity, ancestry, file scope, tree mode, CHANGELOG
content, or predecessor admission cannot be proved, classification falls back
to the complete main admission suite. A source change can therefore never
inherit the CHANGELOG-only result.
Any PR that touches `CHANGELOG.md` but also changes another file runs the same
content contract in `Policy` with `--content-only`. That mode permits the
@@ -65,13 +84,28 @@ second file but still rejects invalid dates or versions, missing bullets,
placeholder `TODO`/`TBD`, unmanaged-section changes, and unsafe tree modes.
Adding a second file therefore cannot bypass CHANGELOG validation.
## Platform and downstream boundaries
## Risk tiers and downstream boundaries
Ordinary PRs run the primary Linux assurance plus fast Darwin/Windows compile
checks. Full native macOS/Windows tests and platform coverage run on a PR only
when its diff touches auth, keychain, OS-specific Go files, installers,
packaging, Formulae, or release automation. Protected `main` pushes run the
complete native matrix.
`Lint` resolves the complete base/head diff before any helper is skipped.
Unknown or truncated input fails closed into the high-risk tier.
| Tier | Selection | Admission work |
|---|---|---|
| Documentation-only | Only prose/documentation assets; no executable, generated, workflow, packaging, or interface surface | Documentation and repository-asset validation; expensive code helpers skip while every required context still succeeds |
| Standard | Ordinary code change with a stable package graph | Race tests for changed Go packages and their reverse dependencies; candidate and merge-base coverage over the same impacted scope and `coverpkg`; representative Darwin/Windows compilation |
| High-risk / protected `main` | Workflow/policy, package add/remove/rename, generated Schema/registry, platform, auth/keychain, installer, packaging, release, transport, recovery, or an unprovable infrastructure classification | Complete race suite and full native macOS/Windows tests, plus every affected domain gate |
Domain helpers (`Edition`, `Interface Integrity`, `CLI Smoke`, and `Mock MCP`,
for example) execute their substantive suites when the diff can affect that
contract or when the high-risk tier is selected. Otherwise their stable named
contexts still report a successful, explicit unaffected result. Release-script
tests follow the same impact rule. This preserves the ruleset contract without
charging every developer for unrelated work.
Platform-sensitive changes additionally run native changed-code coverage.
Protected `main` always runs native tests; generic portable changes are held to
the Linux changed-code gate rather than being forced to manufacture
platform-only coverage.
Complete `Multi-profile E2E` is not a PR admission context. It belongs to the
`Main Integration — 主干集成` workflow and runs only after a push to `main` (or
@@ -97,9 +131,28 @@ flowchart TB
MAIN --> RELEASE["Release delivery"]
```
## Review ownership and auto-merge
A base-owned `pull_request_target` workflow routes newly opened, updated,
reopened, or newly ready PRs targeting `main` to one eligible peer reviewer. It
does not check out or execute PR code, excludes both the author and the known
latest pusher, and balances the open requested-review load across the reviewed
maintainer pool. A current-head approval or change request is preserved; after
a new push, stale activity does not suppress a fresh request, and an
outstanding change requester is preferred for continuity.
The branch ruleset keeps one human approval and all nine strict required
contexts, and requires someone other than the latest pusher to approve after
the most recent head update. Repository auto-merge is enabled for ready PRs,
so a PR merges after that approval and the current revision's nine checks are
green. If `main` advances, strict checks rerun before merge. The reviewer
router is orchestration, not a quality context, and must not be added to the
ruleset.
## Running focused gates locally
Run the contracts relevant to the change:
Run the contracts relevant to the change. Ordinary contributors are not
expected to repeat every CI job locally:
```sh
make build
@@ -120,15 +173,18 @@ base_ref=$(git merge-base HEAD origin/main)
./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD
```
`make coverage-gate` is an enforcement step, not a profile generator. CI
generates the candidate, supporting, merge-base, and (when risk-selected)
native profiles before the aggregate `Coverage` context evaluates them. The
`make coverage-gate` is an enforcement step, not a profile generator. For a
standard PR, CI derives changed packages and their reverse-dependency test
closure, then generates candidate and merge-base profiles with the same test
scope and `coverpkg`. High-risk and protected-main runs use the complete
profiles. Supporting and (when platform-selected) native profiles are
generated before the aggregate `Coverage` context evaluates them. The
aggregate and native gates require 100% coverage for changed executable Go
statements. Overall coverage remains an unrounded, zero-tolerance merge-base
non-regression check. Candidate and baseline profiles are evaluated by the
same block-deduplicating checker; supporting policy and shortcut profiles
contribute to changed-code coverage only. The checked-in badge is presentation
only and is never read as a gate input.
statements. Overall coverage remains an unrounded, zero-tolerance,
scope-matched merge-base non-regression check. Candidate and baseline profiles
are evaluated by the same block-deduplicating checker; supporting policy and
shortcut profiles contribute to changed-code coverage only. The checked-in
badge is presentation only and is never read as a gate input.
Compatibility checks derive authoritative Interface snapshots from the PR
merge-base and the latest reachable stable release. The candidate cannot bless
@@ -156,3 +212,7 @@ Do not require helper jobs, `Multi-profile E2E`, or an aggregate admission
alias. Update ruleset contexts only after the new names have appeared on the
protected branch, so a rename cannot silently remove enforcement or leave an
unproducible required context.
The branch ruleset also requires one approval after the latest push. Enable
repository auto-merge and automatic head-branch deletion; keep the base-owned
reviewer router outside the required-context list.
+203
View File
@@ -0,0 +1,203 @@
# 5分钟搭好团队知识库:DWS 让 IT 服务台自己开口答疑,你值得拥有
> 💡 **省流版摘要:**
> 别再用鼠标一个个建文档了,真的没必要!本文带你用 DWS(钉钉命令行工具),5 分钟搭好一个团队知识库:建空间、搭目录、批量导入 FAQ、配权限,全程命令行一步到位。最后再花 30 秒把它挂到钉钉机器人上——同事在群里 @ 一下,知识库自己开口答疑。知识管理员从"搬运工"升级为"甩手掌柜",就是这么简单。
>
> (温馨提示:本文内容可以直接丢给智能体,让智能体一次性逐步安装和使用)
## 一、痛点吐槽:管知识库有多累?
身为企业 IT / 知识管理员,你肯定经历过这些"九九八十一难"……
- **点击马拉松**:建空间点 5 下、建文件夹点 3 下、建一篇文档再点 4 下。一个季度下来,鼠标点击次数比写的字还多。
- **搬运工噩梦**:几百篇历史 FAQ 散落在本地 Word / Markdown 里,要搬进钉钉知识库?复制粘贴到天荒地老,格式还经常翻车。
- **权限苦差**:新同事入职要加权限、转岗要改角色、离职要移除。逐个空间点进去操作,漏一个就是安全隐患。
- **知识沉睡**:库是建好了,可同事还是习惯私聊问你"VPN 又连不上了怎么办"。知识库躺着吃灰,你继续当人肉客服。😭
今天,DWS(DingTalk Workspace CLI)闪亮登场!🌟
你不需要写一行代码,只要在终端敲几行命令,知识库的"建、搬、管、用"全链路一次搞定。更香的是:搭好的知识库可以直接挂到钉钉机器人上,让知识自己开口答疑。
## 二、DWS 是个啥?知识库的"遥控指挥中心"
DWS 是钉钉能力的原子化封装,把复杂的 OpenAPI 打包成简单指令。管知识库这件事,主要靠它的"三驾马车":
| 命令族 | 能干什么 |
|---|---|
| 🗂️ `dws wiki` | 知识库空间、目录节点、成员权限的全生命周期管理 |
| 📄 `dws doc` | 文档内容读写、本地文件批量导入、模板套用 |
| 📁 `dws drive` | 钉盘文件上传下载、全局搜索、归档备份 |
你可以把它想象成知识库的"遥控指挥中心" 🎮——既能你手动按(终端敲命令,比点界面快 10 倍),也能让 AI 帮你按(Claude Code、Qoder 等智能体直接听懂并调用)。
**适合谁用:**
- **企业 IT / 知识管理员**:批量建库、批量导入、批量管权限,脚本化解放双手
- **开发者 / AI 玩家**:把知识库挂到机器人上,打造 24 小时答疑小能手
- **重度钉钉用户 / 效率党**:一条命令搜全库,比在界面里翻目录快得多
## 三、搭好的知识库能帮你做什么?
| 场景 | 玩法 |
|---|---|
| 🛟 IT 服务台 FAQ 库 | VPN、邮箱、打印机常见问题集中沉淀,机器人自动答疑 |
| 📜 制度流程库 | 报销、请假、采购制度批量导入,全文秒搜 |
| 🎓 新人上岗手册 | 按部门建目录,入职即授权限,自助通关 |
| 🤖 知识库 + 机器人 | `--knowledge-source wiki:<spaceId>` 一挂,群里 @ 它就答 |
所想即所得,拒绝画饼,直接上菜!🍽️
## 四、5分钟倒计时,搭好你的团队知识库
> 以下命令全部经过真实环境跑通验证,放心照抄。
### 0. 安装并登录 DWS(约 1 分钟)
把以下指令复制给你的智能体(Claude Code、Qoder、Codex 等)执行,或手动在终端跑:
macOS / Linux:
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
```
Windows(PowerShell):
```powershell
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
```
登录(提示授权请扫码):
```bash
dws auth login
```
【截图位:dws auth status 显示 token_valid: true】
### 1. 建一个知识库空间(10 秒)
```bash
dws wiki space create --name "IT服务台知识库" --desc "IT 常见问题与制度流程"
```
返回里的 `workspaceId` 就是空间的身份证号,后面每步都要用它。
【截图位:返回 workspaceId 与 spaceUrl】
### 2. 搭目录结构(20 秒)
知识库的结构 = 文件夹节点 + 文档节点。先建分类文件夹:
```bash
dws wiki node create --workspace <workspaceId> --name "常见问题FAQ" --type folder
dws wiki node create --workspace <workspaceId> --name "制度流程" --type folder
```
在文件夹下建一篇空文档(不加 `--folder` 就建在根目录):
```bash
dws wiki node create --workspace <workspaceId> --name "VPN连接失败排查指南" --folder <文件夹nodeId>
```
### 3. 批量导入历史文档(1 分钟,重头戏!)
几百篇本地 FAQ 不用复制粘贴,`doc import` 直接整批灌进知识库,Word、Excel、Markdown、txt 通吃:
```bash
# 单篇导入到指定文件夹
dws doc import --file ./vpn-faq.md --workspace <workspaceId> --folder <文件夹nodeId> --name "VPN连接失败排查指南"
# 批量导入整个目录(bash 一把梭)
for f in ./faq/*.md; do
dws doc import --file "$f" --workspace <workspaceId> --folder <文件夹nodeId>
done
```
已有在线文档想补内容?Markdown 直接写入:
```bash
dws doc update --node <文档nodeId> --content-file ./补充内容.md --mode append
```
【截图位:终端批量导入的滚动输出 + 知识库里齐刷刷的文档列表】
### 4. 配权限:把人拉进来(30 秒)
```bash
# 先用通讯录查到同事的 userId
dws contact user search --query "张三"
# 加为编辑者(--users 支持逗号分隔批量加)
dws wiki member add --workspace <workspaceId> --users <userId1>,<userId2> --role EDITOR
# 随时盘点成员
dws wiki member list --workspace <workspaceId>
```
### 5. 验收:搜一下,秒级命中(10 秒)
```bash
# 库内全文搜索
dws wiki node search --workspace <workspaceId> --query "VPN"
# 全局搜知识库空间
dws wiki space search --query "IT服务台"
```
【截图位:搜索结果命中文档标题】
### 6. 封神一步:挂到机器人上,知识自己开口答疑(30 秒)
如果你已经按《5分钟抱走你的嘴替机器人》建好了钉钉机器人,只需加一个参数:
```bash
dws dev connect --channel claudecode \
--robot-client-id <你的机器人ID> --robot-client-secret <你的机器人密钥> \
--knowledge-source wiki:<workspaceId>
```
机器人会自动从知识库拉取知识并缓存。同事在群里 @ 它问"VPN 连不上怎么办",它直接引用你刚导入的排查指南回答——你,终于不用当复读机了。😎
## 五、进阶使用技巧
### 知识库管理速查表
| 操作 | 命令 |
|---|---|
| 列出我的个人空间 | `dws wiki space list --type myWikiSpace` |
| 列出组织知识库 | `dws wiki space list --type orgWikiSpace` |
| 浏览库内节点树 | `dws wiki node list --workspace <ID> [--folder <nodeId>]` |
| 移动 / 复制节点 | `dws wiki node move` / `dws wiki node copy` |
| 改成员角色 | `dws wiki member update --users <UID> --role VIEWER` |
| 移除成员 | `dws wiki member remove --users <UID>` |
| 删除整个空间 | `dws wiki space delete --workspace <ID>`(进回收站,可恢复) |
### 老手避坑指南 ⛳
- 建在线表格用 `--type axls`,**`asheet` 服务端不支持**,别踩坑。
- `member list` 只返回姓名和角色、**不返回 userId**;要串联 `update` / `remove`,先用 `dws contact user search --query "<姓名>"` 反查。
- 搜索关键词的 flag 是 `--query`,`--keyword` 是遗留别名,新脚本请用 `--query`。
- 所有命令加 `--format json`,配合 `--jq` 过滤字段,写脚本时稳得一批。
- 破坏性操作(删空间、覆盖写文档)前加 `--dry-run` 先预览,确认无误再执行。
### 让机器人答得更好
| 开关 | 作用 |
|---|---|
| `--knowledge-source wiki:<spaceId>` | 从钉钉知识库拉知识作为答疑来源(本文主角) |
| `--knowledge-dir <目录>` | 挂本地 .md/.txt 知识目录,可与上面并存 |
| `--allowed-groups / --allowed-users` | 白名单,只让指定群或人触发 |
| `--daemon` | 后台常驻,关掉终端也不断线 |
## 六、更多 DWS 官方信息
- 钉钉 CLI 官网:https://open.dingtalk.com/dingtalk-cli
- 开源仓库:https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli
- 上一篇姊妹篇:《5分钟抱走你的嘴替机器人:启动钉钉DWS,你值得拥有》
## 七、欢迎加入交流群
【截图位:DWS 交流群二维码】
遇到问题来群里喊一声,官方同学在线答疑。下一篇想看什么?批量备份知识库?给知识库做权限审计?留言区点菜!🍻
+38
View File
@@ -5,6 +5,8 @@
| Variable | Purpose / 用途 |
|---------|---------|
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
| `DWS_AGENT_PRODUCT` | Optional, caller-declared Agent product sent through the existing HTTP `claw-type` header (for example `qwenwork`). Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9_-]*$`. Unset or empty values preserve the edition default (`openClaw` in the open-source build). / 可选、由调用方声明的 Agent 产品标识,经校验后覆盖 HTTP `claw-type` 请求头;未设置或为空时保持当前发行版默认值 |
| `DWS_AGENT_HOST` | Optional, caller-declared Agent runtime form sent as `x-dws-agent-host` (for example `cloud` or `desktop`). Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. / 可选、由调用方声明的 Agent 运行形态,经校验后作为 `x-dws-agent-host` 发送;未设置时省略 |
| `DWS_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
@@ -12,6 +14,42 @@
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 |
| `DWS_DISABLE_KEYCHAIN` | macOS only. Set `1` to skip system Keychain for the encryption key and use file-based storage (same scheme as Linux). For sandboxed runtimes (e.g. Codex App) that block Keychain APIs. Weakens at-rest protection — DEK and ciphertext live in the same directory. / 仅 macOS。设为 `1` 时跳过系统 Keychain,密钥以文件形式存储(与 Linux 一致)。用于 Keychain API 被拦截的沙盒环境(如 Codex App)。代价是 DEK 与密文同目录,保护强度低于默认方案 |
### Agent Product and Host trust model / Agent 产品与运行形态的信任模型
`DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` are caller-declared selection and
observation signals. They are not credentials, attestations, or proof of the
calling host's identity. DingTalk services may record them for logs/BI and may
combine supported values with separately authenticated context for PAT
compatibility, PAT identity/source derivation, or Discovery eligibility. A
service must allowlist supported values and must never grant access, bypass
authentication, or skip authorization solely because either Header claims a
particular product or runtime form. They are not used to select ordinary MCP
tool endpoints.
`DWS_AGENT_PRODUCT` controls only the HTTP `claw-type` Header. The similarly
named `clawType` tool argument on IM send operations is an independent
message-display axis used for the “Send from AI” label. It remains controlled
by the active edition's `ClawTypeValue` and `--ai-tag`; changing
`DWS_AGENT_PRODUCT` does not change that message label.
For QwenWork, report the dimensions separately:
```bash
DWS_AGENT_PRODUCT=qwenwork
DWS_AGENT_HOST=cloud # or desktop
```
Do not set arbitrary product values that the target service has not explicitly
enabled. Older combined Host labels such as `qwenwork_cloud` still satisfy the
generic syntax for compatibility, but new integrations should use the
two-dimensional convention above.
`DWS_AGENT_PRODUCT` 和 `DWS_AGENT_HOST` 均由调用方声明,不是认证凭据,也不能证明
真实宿主身份。服务端可以在独立认证上下文中将受支持值用于日志/BI、PAT 兼容策略、
PAT 身份/来源派生或 Discovery 准入,但不得仅凭这两个 Header 放权、绕过认证或跳过
授权。HTTP `claw-type` 与 IM 消息发送参数 `clawType` 是两个独立维度;后者仅控制
“Send from AI”展示,仍由发行版 `ClawTypeValue` 和 `--ai-tag` 决定。
## Exit Codes / 退出码
| Code | Category | Description / 描述 |
+38 -31
View File
@@ -1,19 +1,28 @@
# 发布手册(预发 / 正式)
发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew 以 workflow 自动创建的 Formula PR 经独立审核合入为交付边界。本地 `dws-release` 仍是兼容入口,但不再要求某一台固定电脑承担打包;不要直接运行 `goreleaser release`,也不要手工补打、移动或复用 tag。
发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew Formula 在不可变 Release 资产及 checksum 通过校验后,由同一 workflow 直接写入 `main`,不再创建二次 PR。本地 `dws-release` 仍是兼容入口,但不再要求某一台固定电脑承担打包;不要直接运行 `goreleaser release`,也不要手工补打、移动或复用 tag。
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`。云端和本地入口都会在封 tag 前检查 immutable releases、当前 SHA 的全部九个 context 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置。
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`。云端入口会在封 tag 前检查 immutable releases、当前 SHA 的全部九个 context、Environment 保护规则和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置。
## 推荐入口:GitHub 云端发布
任何具有仓库写权限、因而可以手动运行 Actions workflow 的成员,都可以基于当时最新的 `main` 发起发布:
入口页面是 [GitHub Actions → Release](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/release.yml)。在仓库页面依次点击 `Actions` → `Release` → `Run workflow` 即可操作;不需要通过 Agent 或本地机器触发。
1. 在 GitHub Actions 打开 `Release`,选择 `Run workflow`,分支必须是默认分支 `main`。
只有得到该仓库明确授权、最终权限为 `write`、`maintain` 或 `admin` 的协作者可以运行发布操作,workflow 还会对发起人和重新运行者做同样的权限复核。没有仓库写权限的外部贡献者以及仅有 `read` / `triage` 权限的成员不能规划或发布版本。两个渠道的授权边界如下:
- beta:上述任一内部成员都可以直接规划和发布,不需要人工审批。
- stable:上述任一内部成员都可以规划并发起发布;完成只读规划和治理检查后,workflow 会在 `release-stable` Environment 等待另一名仓库管理员通过 `Review deployments` 签收。申请人不能批准自己的请求,批准后原 run 自动继续,无需重新触发。
基于当时最新的 `main` 发起发布:
1. 在上述 `Release` 页面选择 `Run workflow`,分支必须是默认分支 `main`。
2. `release_operation=plan`,选择 `release_channel=beta|stable`;仅在开始新 beta 线时选择 `release_bump=patch|minor|major`。
3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。
4. 再次运行,改为 `release_operation=publish`,并输入 `PUBLISH beta` 或 `PUBLISH stable`。
4. 再次运行,改为 `release_operation=publish`。beta 会直接进入自动化发布;stable 会在封 tag 前等待管理员签收。
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew PR DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、命令兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
@@ -62,18 +71,20 @@ dws-release
dws-release config --remote origin
```
之后命令按仓库状态自动走到正确步骤:缺少精确 CHANGELOG 章节时只生成模板并停止;补全、提交并合入 `main` 后,再运行同一条命令就会安全快进本地 `main` 并执行完整预检。若同名 remote 后续被改指向其他仓库会直接拒绝。只有显式增加 `--publish` 才会进入 tag 发布,且底层仍要求最终版本确认。
之后命令按仓库状态自动走到正确步骤:缺少精确 CHANGELOG 章节时只生成模板并停止;补全、提交并合入 `main` 后,再运行同一条命令就会安全快进本地 `main` 并执行完整预检。若同名 remote 后续被改指向其他仓库会直接拒绝。官方仓库不再接受本地 `--publish`,命令会直接提示上述 Actions 页面;本地入口不能绕过 beta/stable 的统一授权。
Release workflow 不再监听新建的 `v*` tag;直接推 tag 不会发布 GitHub Release、npm 或镜像渠道。所有新 beta/stable 都必须从云端页面进入统一授权和审计链路;历史失败版本仍可通过受保护的 recovery 兼容处理。
## 发布模型
```text
main 上的候选代码 + beta CHANGELOG
→ vX.Y.Z-beta.N(预发验证)
→ 只允许补正式 CHANGELOG,源码不得再变化
→ vX.Y.Z(正式发布)
→ 补正式 CHANGELOG;允许继续通过 PR 合入新 commit
→ vX.Y.Z(正式发布,封板提交必须包含该 beta 提交)
```
云端入口自动选择本次最新、已交付且未撤回的 beta;本地入口必须显式指定。流水线会比较两者:除 `CHANGELOG.md` 外只要有任何文件变化,就拒绝正式发布。这样预发测过的代码、命令树和正式发布的代码是同一份。
云端入口自动选择本次最新、已交付且未撤回的 beta;本地入口必须显式指定。流水线要求该 beta 已成功交付、未撤回,且 beta 提交必须位于正式发布封板提交的历史中——不能跳过 beta 直接发正式版,但允许在 beta 之后把经过 review 合入 `main` 的 commit 一起发布。
## 预发发布
@@ -89,13 +100,7 @@ dws-release v1.2.3-beta.1
dws-release v1.2.3-beta.1
```
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后会在当前 Git worktree 的私有 Git 状态目录写入一个有效期六小时的证明,绑定版本、精确 commit、发布仓库、beta/stable 基线和远端 `main`:
```bash
dws-release v1.2.3-beta.1 --publish
```
若源码、版本、远端身份和 stable 基线均未变化,`--publish` 会复用该证明,只执行远端契约、发布身份和最终治理复核,不再重复测试与打包。也可以直接运行 `--publish`;没有可复用证明时只会完整执行一次预检。命令在封 tag 前仍要求再次输入完整版本号,统一入口不提供跳过确认的参数。
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
## 正式发布
@@ -105,14 +110,13 @@ beta 验证通过后,运行正式版入口:
dws-release v1.2.3 --from-beta v1.2.3-beta.1
```
首次运行只生成正式版 CHANGELOG 并停止。补全内容、删除 `TODO`,提交后通过 PR 合入 `main`;重新运行同一条命令做完整预检,确认后增加 `--publish`:
首次运行只生成正式版 CHANGELOG 并停止。补全内容、删除 `TODO`,提交后通过 PR 合入 `main`;重新运行同一条命令做完整预检:
```bash
dws-release v1.2.3 --from-beta v1.2.3-beta.1
dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
```
本地入口的 `FROM_BETA` 不会自动推断;云端入口会按上述规则唯一选择。两种入口都会把它写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。
预检通过后,在 Actions 页面选择 stable 和 `release_operation=publish`。云端入口会按上述规则唯一选择 beta,并把它写入 stable annotated tag 的 `From-Beta` 元数据;在创建 tag 前必须由另一名仓库管理员签收。
## CHANGELOG 契约
@@ -130,14 +134,14 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
## CI/CD 保证
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走受保护恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
- tag 必须是 annotated tag;本地脚本在推送前重新确认 HEAD 与远端 `main` 完全一致,CI 允许其后 `main` 前进,但要求封板提交仍位于 `main` 历史中。
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
- tag 必须由云端 seal job 创建为 annotated tag;封板提交必须已通过 PR 合入并包含在远端 `main` 历史中。流水线允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;版本规划、云端封板、发布、恢复、修复和撤回共享同一发布锁。
- 本地 tag push 失败时会删除本次新建的本地 tag。远端 tag 一旦创建,后续发布归 CI 所有;发布中途失败时走受保护恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 tag。
- 云端 seal 创建远端 tag 后,后续发布归同一 run 所有;发布中途失败时先重跑同一 run 的失败 jobs,必须跨 run 时走机器核验恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 tag。
npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_version`。它只支持启用 immutable releases 后、由本流水线成功产出的公开 immutable release:目标必须是 `main` 历史中的 annotated tag,并且同 commit 的 `Build immutable GitHub Release` job 已成功。即使后续 npm 分发失败,这个独立的产物封存边界仍可作为补发依据。补发会用目标 commit 的 npm 模板重组包,逐平台核验资产和二进制版本,再发布到隔离的 `backfill` dist-tag,不会回滚 `latest` / `beta`。历史 mutable release 不进入自动补发路径,避免把可被替换的资产带入 npm。
@@ -164,32 +168,35 @@ dws-release recover v1.2.3-beta.1
- 输入精确绑定原 annotated tag object、commit 和失败的 sealed `Release` run;云端 run 还必须与 tag 内的 run ID、attempt、requester 完全一致,commit 必须仍在 `main` 历史中。
- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本不能全量重建:单个下游故障走对应的 channel repair,版本本身有问题则走受保护的全平台 withdrawal。
- `release-recovery` environment 必须限制为受保护分支、配置至少一名 required reviewer,并禁止自审;workflow 会通过 API 复核这些设置,未配置时 fail closed。
- 恢复不再进入人工审批 environment。workflow 会机器核验 tag object、commit、原失败 run/attempt、请求人、完整 seal metadata、`main` 祖先关系以及 Release 状态;任一事实不一致都会在构建前 fail closed。
- 恢复复用正常的 contract、构建、Developer ID 签名、资产校验、immutable 发布、Homebrew、npm,以及已启用的 OSS jobs,不存在 recovery 专用 publisher 或门禁跳过。
- 如果 GitHub Release 已在 recovery 中封存、后续 Homebrew/npm 校验发生瞬时失败,只重跑该 run 的 failed jobs;流水线仅在隐藏 run marker、tag object、commit 和 finalized artifact 字节全部精确一致时复用公开 Release。
成功的默认分支恢复 run 会成为后续 beta → stable 和 stable baseline 验证的可审计交付证据;历史临时分支恢复仍只接受 reviewed manifest 中的固定证据。
云端 seal 后不要使用 GitHub 的 “Re-run failed jobs” 作为交付修复:annotated tag 永久绑定最初的 run attempt,普通 rerun 不会成为可接受的交付证据。GitHub Release 尚未公开时走上述 protected recovery;已经公开且仅 npm/OSS/Gitee 某一渠道失败时走对应 repair;版本内容本身有问题时走 withdrawal。
seal job 写入 tag 后如果只因 GitHub API 瞬时 404/429/5xx 或后续 job 失败,可直接使用 GitHub 的 “Re-run failed jobs”。同一 run 会精确复用原 release-plan;seal 只在 version、tag object、commit、channel、beta 来源、OSS policy、请求人、run ID 和完整 message 全部匹配且原 attempt 不大于当前 attempt 时认领已有 tag。不同 run 或任一字段不匹配时不会认领。GitHub Release 尚未公开且必须跨 run 重建时走上述机器核验 recovery;已经公开且仅 npm/OSS/Gitee 某一渠道失败时走对应 repair;版本内容本身有问题时走 withdrawal。
OSS 的 `latest.txt` / `beta.txt` 是镜像频道元数据;当前仓库安装器仍主要从 GitHub/Gitee 解析版本。启用 OSS 后,发布和撤回把它作为受控分发渠道处理,保证一旦外部消费者接入该 pointer,也不会继续解析到已撤回版本;未启用时两条流程都明确跳过不存在的 OSS 渠道。
Release workflow 会生成 Darwin/Linux 双架构 Formula,并分别为 stable/beta 打开 Homebrew PR;tap 的默认分支仍以独立审核合入为交付边界。撤回 workflow 使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。
Release workflow 会生成 Darwin/Linux 双架构 Formula,并在不可变资产逐个校验后,由 `HOMEBREW_PR_TOKEN` 所属的受控发布身份只提交对应 stable 或 beta Formula 文件到 `main`,不再创建二次发布 PR;并发 `main` 更新会以全新 clone 最多重试三次,绝不 force push。该身份的提交不会依赖另一轮 CI 来补齐证明:workflow 只在确认该 commit 单父、唯一改动为目标 Formula、内容与本次已验证产物逐字节一致,且父 commit 九项 Code Admission 全绿后,直接为 Formula-only commit 封存同名九项成功 checks,避免下一次发布因缺失 contexts 被卡住。撤回 workflow 暂时仍使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。
## 平台治理前置
仓库管理员还需要在 GitHub 平台配置以下不可由脚本替代的规则:
- `main` 必须精确要求 `Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP` 九个 Code Admission context;tag workflow 也会通过 Checks API 再确认该封板 SHA 上九项全部成功。
- `main` 必须精确要求 `Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP` 九个 Code Admission context;Release workflow 也会通过 Checks API 再确认该封板 SHA 上九项全部成功。
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
- tag ruleset 还必须覆盖 `withdrawn/v*`:只允许受保护的撤回 workflow 创建墓碑,禁止更新或删除墓碑;同时应允许 Release workflow 创建新的 `v*`,允许撤回 workflow 在全部渠道回退后删除精确的问题 `v*`。若组织级规则阻止这两个 workflow 的预期动作,发布或撤回会 fail closed,不能靠手工移动 tag 绕过。
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和云端发布都使用这一个身份进行 fail-closed 验证。
- 配置 `APPLE_CERTIFICATE_P12_BASE64`、`APPLE_CERTIFICATE_PASSWORD` 和具备发布权限的 `NPM_TOKEN`;撤回还要求该 npm 身份能够执行 `deprecate` 和修改 dist-tag。
- 启用 OSS 镜像时,先创建有效 Bucket,再设置仓库变量 `ENABLE_OSS_MIRROR=true`,并配置 `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET`,按需配置 `OSS_PREFIX`。启用后发布保持 fail-closed;撤回身份必须能够补齐安全版本资产、写 `latest.txt` / `beta.txt` 并删除问题版本前缀。尚未 provision Bucket 时保持该变量未设置或不等于 `true`,新 tag 会封存 `OSS-Mirror: deferred` 并跳过 OSS;该版本不能通过现有 repair 流程事后改成启用。
- 若启用 Gitee fallback,设置 `ENABLE_GITEE_UPLOAD_FALLBACK=true`,并配置 `GITEE_TOKEN`、`GITEE_USER`、`GITEE_REPO`;该身份必须能够创建和删除目标仓库的 Release 与 tag。
- 单独配置 `HOMEBREW_PR_TOKEN`,优先使用仅授权本仓库且具备 `Contents: write`、`Pull requests: write` 的 fine-grained PAT;若组织策略不允许该账号使用 fine-grained PAT,则回退到仅带 `public_repo` scope 的专用 classic PAT。治理预检和 tag contract 会验证 token 身份、classic scope,并用 `[skip ci]` 临时分支和 draft PR 完成真实写权限 canary,随后立即关闭 PR、删除分支;任何清理失败都会 fail closed。门禁也会拒绝与治理 token 复用。
- 创建 `release-recovery` environment,只允许受保护分支,设置 required reviewer、禁止自审并关闭管理员绕过。workflow 会读取 environment 的 required-reviewer、prevent-self-review 和 protected-branch 规则;规则缺失时紧急恢复会失败,正常 beta/stable tag 发布不受影响。
- 正常 Homebrew 发布使用现有的 `HOMEBREW_PR_TOKEN` 直接提交 Formula-only commit,不再创建 Homebrew PR,也不跑权限 canary。GitHub 不允许内置 Actions App 作为当前仓库 ruleset 的 bypass actor,因此两个默认分支 ruleset 都只给该 token 所属的指定发布管理员用户 `always` bypass;仓库脚本仍会限制提交路径、校验 Ruby、禁止 force push,并在并发更新时重新基于最新 `main`。
- `HOMEBREW_PR_TOKEN` 应保持仓库范围的 `Contents: write` 与 `Pull requests: write` 权限;后者仅供撤回流程创建回退 PR。不要与 `RELEASE_GOVERNANCE_TOKEN` 复用,并定期审计 token owner 与 ruleset bypass actor 一致。
- 创建 `release-beta` environment,只允许受保护分支且不配置 required reviewer;仓库内部 `write`、`maintain`、`admin` 成员的 beta 发布会直接通过该边界。
- 创建 `release-stable` environment,只允许受保护分支,以仓库管理员为 required reviewer,禁止申请人自审并关闭管理员绕过。内部成员可以发起 stable,但必须由另一名管理员签收后才能封 tag 和写入任何发布渠道。
- Release workflow 会在封 tag 前回读并验证上述两套 Environment 规则;规则缺失、stable reviewer 不再是仓库管理员、或 beta 被误加人工审批时都会 fail closed。
- 创建 `release-withdrawal` environment,只允许受保护分支,设置至少一名 required reviewer、禁止申请人自审并关闭管理员绕过。撤回 workflow 会通过 API 复核这些规则;任何一项缺失都会在触碰 npm、OSS、Gitee、Homebrew 或 GitHub Release 前失败。
- 仓库或组织的 Actions 策略必须允许 `Release` 与 `Withdraw release` workflow 的 `GITHUB_TOKEN` 获得各 job 声明的 `contents: write`。若上述发布凭证采用 environment secret,确认 `release-withdrawal` 审批完成后能够读取撤回所需的 npm、OSS、Gitee 和 Homebrew 凭证。
- 仓库或组织的 Actions 策略必须允许 `Release` 与 `Withdraw release` workflow 的 `GITHUB_TOKEN` 获得各 job 声明的权限;正常发布由 `HOMEBREW_PR_TOKEN` 更新两个受控 Formula 路径,内置 token 只承担 workflow 自身声明的封板与校验写入。若撤回凭证采用 environment secret,确认 `release-withdrawal` 审批完成后能够读取撤回所需的 npm、OSS、Gitee 和 Homebrew 凭证。
immutable releases,或任一 Code Admission context 缺失、未成功时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
+8 -6
View File
@@ -8,7 +8,7 @@ DWS Schema 是当前二进制公开 CLI 的版本化 Agent 执行契约。它描
1. **Schema 描述 CLI,不制造 CLI。** `CommandRegistry`、manual hint、metadata 和 Catalog 都不能凭空创建 Cobra 命令或 flag;registry 中的每个路径都必须精确绑定真实 runnable Cobra leaf。
2. **所有来源只解析一次。** 来源经过统一 resolver 进入 typed `SchemaRegistry`,所有查询、导出和门禁都消费同一个 `SchemaRegistry/SchemaIndex`。
3. **Registry-first,Catalog 只出不进。** reviewed `CommandRegistry` 是稳定 command identity/navigation 的唯一事实源;`schema_catalog.json` 和其他生成 JSON 只是下游发布物,不能成为命令、metadata 或下一轮 Catalog 的来源。运行时 production loader 解码 embedded snapshot 只是交付边界,不是 source resolution。
3. **Registry-first,Catalog 只出不进。** reviewed `CommandRegistry` 是稳定 command identity/navigation 的唯一事实源;`schema_catalog/`(`catalog.json` + 每产品 `tools/<product>.json`)和其他生成 JSON 只是下游发布物,不能成为命令、metadata 或下一轮 Catalog 的来源。运行时 production loader 解码 embedded snapshot 只是交付边界,不是 source resolution。
Schema 不调用 MCP `tools/list`,不访问网络,也不读取用户本地 discovery cache。
@@ -63,8 +63,9 @@ live Cobra flag facts / typed parameter metadata
build-time typed gates snapshot serializer
|
v
schema_catalog.json
(release output only)
schema_catalog/
(catalog.json + tools/<product>.json,
release output only)
|
v
go:embed -> typed loader
@@ -130,7 +131,7 @@ DWS 当前对外仍保留兼容 wire:leaf 使用 flat `parameters`,安全和
| `schema_mcp_metadata.json` | pinned RPC identity、接口描述和脱敏参数事实 | CLI identity、运行时路由、risk 推断 |
| `schema_hints/selection/*.json` | reviewed selection prose(summary / use_when / avoid_when / examples) | 创建 Cobra 命令或参数、改写 safety |
| Skills/Markdown | 产品路由、工作流和使用建议 | 命令存在性和 flag 事实 |
| `schema_catalog.json` 及其他 generated JSON | resolved registry 的兼容发布序列化;运行时由 production loader 解回 typed registry/index | generation/source resolution 输入、identity fallback、手工修复源 |
| `schema_catalog/`(catalog.json + tools/<product>.json)及其他 generated JSON | resolved registry 的兼容发布序列化;运行时由 production loader 解回 typed registry/index | generation/source resolution 输入、identity fallback、手工修复源 |
`schema_command_registry.json` 承载 reviewed `CommandRegistry`。Manual command addition 先以确定性规则合并进 effective registry;从 binder 开始,下游只看到一个稳定 identity/navigation 模型。旧 wire 中的 `surface_hash` / `surface_tools` 字段仅为兼容名称,语义已经是 effective Registry hash/coverage,不构成第二事实源。
@@ -272,7 +273,8 @@ dws schema --all # 所有工具的完整 leaf 导
- `internal/cli/schema_agent_metadata/index.json`
- `internal/cli/schema_agent_metadata/<product>.json`
- `internal/cli/schema_agent_metadata_audit.json`
- `internal/cli/schema_catalog.json`
- `internal/cli/schema_catalog/catalog.json`(全局信封 + Catalog)
- `internal/cli/schema_catalog/tools/<product>.json`(每产品 leaf ToolSpecs,按产品分片以免并发 PR 冲突)
只编辑来源;不要手工编辑 Agent metadata 或 Catalog 输出。
@@ -303,7 +305,7 @@ go test ./internal/cli ./internal/app ./internal/generator/... -count=1
## 10. 明确禁止
- 运行时调用 MCP `tools/list` 或访问网络生成 Schema。
- 从旧 `schema_catalog.json` 或其他 generated JSON 反向创建/补齐 Cobra leaf、flag、CommandRegistry 或下一轮 Catalog。
- 从 `schema_catalog/` 等生成 JSON 反向创建/补齐 Cobra leaf、flag、CommandRegistry 或下一轮 Catalog。
- 把 native annotation、legacy registry 或 Catalog 当作 identity fallback;或在 `EffectiveCommandRegistry` 之后再次选择 identity winner。
- renderer、query 或 gate 在 `SchemaRegistry` 之后重新读取 source 并做第二次 merge。
- 用 prefix/wildcard exclusion 隐藏未来命令。
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
// 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 (
"regexp"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
)
const (
envDWSAgentHost = "DWS_AGENT_HOST"
headerDWSAgentHost = "x-dws-agent-host"
maxAgentHostBytes = 64
)
var agentHostPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
func init() {
configmeta.Register(configmeta.ConfigItem{
Name: envDWSAgentHost,
Category: configmeta.CategoryExternal,
Description: "调用 DWS 的 Agent 运行形态标识;服务端可结合产品用于观测和 PAT 兼容策略",
Example: "cloud",
})
}
// parseAgentHost normalizes and validates the caller-declared runtime-form
// signal. Only surrounding ASCII spaces and tabs are trimmed; other control
// or Unicode whitespace remains visible to validation and is rejected. An
// unset or ASCII-whitespace-only value means "do not emit".
func parseAgentHost(raw string) (string, error) {
if strings.ContainsAny(raw, "\r\n") {
return "", invalidAgentHostError()
}
value := strings.Trim(raw, " \t")
if value == "" {
return "", nil
}
if len(value) > maxAgentHostBytes {
return "", invalidAgentHostError()
}
if !agentHostPattern.MatchString(value) {
return "", invalidAgentHostError()
}
return value, nil
}
func invalidAgentHostError() error {
// Do not include the raw environment value in the error: it is an
// untrusted caller-controlled string and may contain sensitive data.
return apperrors.NewValidation(
"DWS_AGENT_HOST must be at most 64 bytes and match ^[a-z0-9][a-z0-9_-]*$",
apperrors.WithReason("invalid_agent_host"),
)
}
+206
View File
@@ -0,0 +1,206 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"errors"
"io"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func TestParseAgentHost(t *testing.T) {
valid := []struct {
name string
raw string
want string
}{
{name: "unset", raw: "", want: ""},
{name: "ASCII whitespace only", raw: " \t ", want: ""},
{name: "cloud", raw: "cloud", want: "cloud"},
{name: "desktop", raw: "desktop", want: "desktop"},
{name: "legacy combined label remains valid", raw: "qwenwork_cloud", want: "qwenwork_cloud"},
{name: "trim", raw: " \tcloud\t ", want: "cloud"},
{name: "generic", raw: "host-2_alpha", want: "host-2_alpha"},
{name: "leading digit", raw: "2nd_host", want: "2nd_host"},
{name: "maximum length", raw: strings.Repeat("a", maxAgentHostBytes), want: strings.Repeat("a", maxAgentHostBytes)},
}
for _, tc := range valid {
t.Run(tc.name, func(t *testing.T) {
got, err := parseAgentHost(tc.raw)
if err != nil {
t.Fatalf("parseAgentHost() error = %v", err)
}
if got != tc.want {
t.Fatalf("parseAgentHost() = %q, want %q", got, tc.want)
}
})
}
invalid := []struct {
name string
raw string
}{
{name: "carriage return", raw: "qwenwork_cloud\r"},
{name: "line feed", raw: "\nqwenwork_cloud"},
{name: "uppercase", raw: "Qwenwork_cloud"},
{name: "internal space", raw: "qwenwork cloud"},
{name: "internal tab", raw: "qwenwork\tcloud"},
{name: "unicode", raw: "千问办公"},
{name: "leading dash", raw: "-qwenwork"},
{name: "leading underscore", raw: "_qwenwork"},
{name: "control character", raw: "qwenwork\x00cloud"},
{name: "vertical tab", raw: "\vcloud"},
{name: "form feed", raw: "cloud\f"},
{name: "next line", raw: "cloud\u0085"},
{name: "non-breaking space", raw: "\u00a0cloud"},
{name: "ideographic space", raw: "cloud\u3000"},
{name: "too long", raw: strings.Repeat("a", maxAgentHostBytes+1)},
}
for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) {
got, err := parseAgentHost(tc.raw)
if err == nil {
t.Fatalf("parseAgentHost(%q) = %q, want error", tc.raw, got)
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) {
t.Fatalf("parseAgentHost() error type = %T, want *errors.Error", err)
}
if appErr.Category != apperrors.CategoryValidation {
t.Fatalf("category = %q, want validation", appErr.Category)
}
if appErr.Reason != "invalid_agent_host" {
t.Fatalf("reason = %q, want invalid_agent_host", appErr.Reason)
}
if tc.raw != "" && strings.Contains(err.Error(), tc.raw) {
t.Fatalf("error must not echo invalid value %q: %v", tc.raw, err)
}
})
}
}
func TestResolveIdentityHeadersAddsAgentHostBeforeEditionMerge(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSAgentHost, " qwenwork_desktop ")
t.Setenv(agentproduct.EnvName, "")
t.Setenv(envDWSChannel, "channel-test")
t.Setenv(envDingtalkAgent, "agent-test")
t.Setenv(authpkg.AgentCodeEnv, "agent-code-test")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
mergeSawAgentHost := ""
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
mergeSawAgentHost = headers[headerDWSAgentHost]
headers["claw-type"] = "test-claw"
return headers
},
})
headers := resolveIdentityHeaders()
if got := mergeSawAgentHost; got != "qwenwork_desktop" {
t.Fatalf("MergeHeaders saw agent host %q, want qwenwork_desktop", got)
}
if got := headers[headerDWSAgentHost]; got != "qwenwork_desktop" {
t.Fatalf("%s = %q, want qwenwork_desktop", headerDWSAgentHost, got)
}
if got := headers["x-dingtalk-source"]; got != "github" {
t.Fatalf("x-dingtalk-source = %q, want github", got)
}
if got := headers["x-dingtalk-dws-agent-code"]; got != "agent-code-test" {
t.Fatalf("agentCode header = %q, want agent-code-test", got)
}
if got := headers["x-dws-channel"]; got != "channel-test" {
t.Fatalf("channel header = %q, want channel-test", got)
}
if got := headers["claw-type"]; got != "test-claw" {
t.Fatalf("claw-type = %q, want test-claw", got)
}
}
func TestResolveIdentityHeadersOmitsAbsentOrInvalidAgentHost(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSChannel, "channel-test")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
return headers
},
})
for _, raw := range []string{"", " \t ", "DO_NOT_ECHO"} {
t.Setenv(envDWSAgentHost, raw)
headers := resolveIdentityHeaders()
if _, ok := headers[headerDWSAgentHost]; ok {
t.Fatalf("%s must be omitted for %q: %#v", headerDWSAgentHost, raw, headers)
}
if got := headers["x-dingtalk-source"]; got != "github" {
t.Fatalf("x-dingtalk-source = %q, want github", got)
}
if got := headers["x-dws-channel"]; got != "channel-test" {
t.Fatalf("channel header = %q, want channel-test", got)
}
}
}
func TestRootRejectsInvalidAgentHostBeforeEditionHook(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
const invalidValue = "DO_NOT_ECHO"
t.Setenv(envDWSAgentHost, invalidValue)
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
hookCalled := false
edition.Override(&edition.Hooks{
AfterPersistentPreRun: func(_ *cobra.Command, _ []string) error {
hookCalled = true
return nil
},
})
root := NewRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"version"})
err := root.Execute()
if err == nil {
t.Fatal("root command accepted invalid DWS_AGENT_HOST")
}
if hookCalled {
t.Fatal("edition AfterPersistentPreRun ran before DWS_AGENT_HOST validation")
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) {
t.Fatalf("root error type = %T, want *errors.Error", err)
}
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != "invalid_agent_host" {
t.Fatalf("root error = category %q reason %q", appErr.Category, appErr.Reason)
}
if strings.Contains(err.Error(), invalidValue) {
t.Fatalf("root error must not echo invalid value: %v", err)
}
}
+74
View File
@@ -0,0 +1,74 @@
// 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 (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func init() {
configmeta.Register(configmeta.ConfigItem{
Name: agentproduct.EnvName,
Category: configmeta.CategoryExternal,
Description: "调用方声明的 Agent 产品标识;覆盖 HTTP claw-type,但不是认证凭据",
DefaultValue: "由当前发行版决定",
Example: "qwenwork",
})
}
// parseAgentProduct converts the reusable package error into the CLI's stable
// structured validation error without exposing the untrusted raw value.
func parseAgentProduct(raw string) (string, error) {
value, err := agentproduct.Parse(raw)
if err != nil {
return "", invalidAgentProductError()
}
return value, nil
}
func invalidAgentProductError() error {
return apperrors.NewValidation(
"DWS_AGENT_PRODUCT must be at most 64 bytes and match ^[A-Za-z0-9][A-Za-z0-9_-]*$",
apperrors.WithReason("invalid_agent_product"),
)
}
// resolveEffectiveAgentProduct resolves the request-header identity with one
// shared precedence rule: a valid non-empty runtime override wins, otherwise
// the edition's MergeHeaders value wins, otherwise the OSS default is used.
// Invalid runtime input falls back here for library callers that bypass root
// validation; normal CLI execution rejects it before network access.
func resolveEffectiveAgentProduct(headers map[string]string) string {
fallback := edition.DefaultOSSClawType
if value := headers[agentproduct.HeaderName]; value != "" {
fallback = value
}
value, err := agentproduct.ResolveFromEnv(fallback)
if err != nil {
return fallback
}
return value
}
func applyAgentProductOverride(headers map[string]string) map[string]string {
value := resolveEffectiveAgentProduct(headers)
if headers == nil {
headers = make(map[string]string)
}
headers[agentproduct.HeaderName] = value
return headers
}
+267
View File
@@ -0,0 +1,267 @@
// 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 (
"errors"
"io"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func TestUnsetAgentProductKeepsOpenSourceDefault(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != edition.DefaultOSSClawType {
t.Fatalf("%s = %q, want %q", agentproduct.HeaderName, got, edition.DefaultOSSClawType)
}
}
func TestParseAgentProductReturnsStableValidationError(t *testing.T) {
const invalidValue = "DO_NOT ECHO"
got, err := parseAgentProduct(invalidValue)
if got != "" {
t.Fatalf("parseAgentProduct() = %q, want empty", got)
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) {
t.Fatalf("parseAgentProduct() error type = %T, want *errors.Error", err)
}
if appErr.Category != apperrors.CategoryValidation {
t.Fatalf("category = %q, want validation", appErr.Category)
}
if appErr.Reason != "invalid_agent_product" {
t.Fatalf("reason = %q, want invalid_agent_product", appErr.Reason)
}
if strings.Contains(err.Error(), invalidValue) {
t.Fatalf("error must not echo invalid value: %v", err)
}
}
func TestResolveIdentityHeadersAgentProductPrecedence(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
headers["x-edition-header"] = "preserved"
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "enterprise-default"
headers["x-enterprise-header"] = "preserved"
return headers
},
})
t.Run("unset keeps edition default", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, "")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "wukong" {
t.Fatalf("%s = %q, want wukong", agentproduct.HeaderName, got)
}
})
t.Run("valid override is final", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, " qwenwork ")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
if got := headers["x-edition-header"]; got != "preserved" {
t.Fatalf("edition header = %q, want preserved", got)
}
if got := headers["x-enterprise-header"]; got != "preserved" {
t.Fatalf("enterprise header = %q, want preserved", got)
}
if got := headers["x-dingtalk-source"]; got != "github" {
t.Fatalf("x-dingtalk-source = %q, want github", got)
}
})
t.Run("invalid library input falls back to edition", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwen work")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "wukong" {
t.Fatalf("%s = %q, want wukong", agentproduct.HeaderName, got)
}
})
}
func TestApplyAgentProductOverrideAllocatesHeaders(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwenwork")
headers := applyAgentProductOverride(nil)
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
}
func TestRootRejectsInvalidAgentProductBeforeEditionHook(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
const invalidValue = "DO_NOT ECHO"
t.Setenv(agentproduct.EnvName, invalidValue)
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
hookCalled := false
edition.Override(&edition.Hooks{
AfterPersistentPreRun: func(_ *cobra.Command, _ []string) error {
hookCalled = true
return nil
},
})
root := NewRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"version"})
err := root.Execute()
if err == nil {
t.Fatal("root command accepted invalid DWS_AGENT_PRODUCT")
}
if hookCalled {
t.Fatal("edition AfterPersistentPreRun ran before DWS_AGENT_PRODUCT validation")
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) {
t.Fatalf("root error type = %T, want *errors.Error", err)
}
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != "invalid_agent_product" {
t.Fatalf("root error = category %q reason %q", appErr.Category, appErr.Reason)
}
if strings.Contains(err.Error(), invalidValue) {
t.Fatalf("root error must not echo invalid value: %v", err)
}
}
func TestEffectiveClawTypeDoesNotInvokeEnterpriseCredentialHeaders(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
hookCalled := false
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
hookCalled = true
headers[agentproduct.HeaderName] = "enterprise-default"
return headers
},
})
t.Setenv(agentproduct.EnvName, "")
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() = %q, want wukong", got)
}
if hookCalled {
t.Fatal("EnterpriseCredentialHeaders hook ran during PAT error serialization")
}
}
func TestAgentProductHeaderIsSeparateFromMessageClawType(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "qwenwork")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
ClawTypeValue: "message-brand",
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
return headers
},
})
if got := resolveIdentityHeaders()[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("HTTP %s = %q, want qwenwork", agentproduct.HeaderName, got)
}
if got := edition.ClawType(); got != "message-brand" {
t.Fatalf("message clawType = %q, want message-brand", got)
}
}
func TestResolveIdentityHeadersRestoresAgentProductAfterNilCredentialHeaders(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "qwenwork")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
credentialHookCalled := false
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
return headers
},
EnterpriseCredentialHeaders: func(map[string]string) map[string]string {
credentialHookCalled = true
return nil
},
})
headers := resolveIdentityHeaders()
if !credentialHookCalled {
t.Fatal("EnterpriseCredentialHeaders hook was not called")
}
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
}
func TestEffectiveClawTypeUsesAgentProductOverride(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
return headers
},
})
t.Setenv(agentproduct.EnvName, "qwenwork")
if got := effectiveClawType(); got != "qwenwork" {
t.Fatalf("effectiveClawType() = %q, want qwenwork", got)
}
t.Setenv(authpkg.AgentCodeEnv, "agent-code")
if got := apperrors.HostControlBlock()["clawType"]; got != "qwenwork" {
t.Fatalf("hostControl.clawType = %q, want qwenwork", got)
}
t.Setenv(agentproduct.EnvName, "")
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() = %q, want wukong", got)
}
t.Setenv(agentproduct.EnvName, "invalid product")
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() with invalid env = %q, want wukong", got)
}
}
+2
View File
@@ -17,6 +17,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"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/edition"
"github.com/spf13/cobra"
)
@@ -346,6 +347,7 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
t.Fatal("host control enabled without agent code")
}
t.Setenv(authpkg.AgentCodeEnv, "agent")
t.Setenv(agentproduct.EnvName, "")
edition.Override(&edition.Hooks{MergeHeaders: func(headers map[string]string) map[string]string { return headers }})
if got := hostControlProviderFromEnv(); got != edition.DefaultOSSClawType {
t.Fatalf("default claw type = %q", got)
+342 -44
View File
@@ -40,12 +40,14 @@ import (
)
type authLoginConfig struct {
Token string
Force bool
Device bool
Recommend bool
Yes bool
TargetCorpID string
Token string
Force bool
Device bool
Recommend bool
Yes bool
TargetCorpID string
HistoryProfileSelector string
HistoryProfileSelectorExplicit bool
}
type authLoginGuideAction string
@@ -148,7 +150,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
provider.Output = cmd.ErrOrStderr()
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data)
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{
Selector: cfg.HistoryProfileSelector,
Explicit: cfg.HistoryProfileSelectorExplicit,
})
}
tokenData, err = authDeviceLogin(provider, loginCtx)
if err != nil {
@@ -163,7 +168,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
provider.TargetCorpID = cfg.TargetCorpID
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data)
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{
Selector: cfg.HistoryProfileSelector,
Explicit: cfg.HistoryProfileSelectorExplicit,
})
}
configureOAuthProviderCompatibility(provider, configDir)
tokenData, err = authOAuthLogin(provider, loginCtx, authLoginForcesAuthorization(cfg))
@@ -175,11 +183,23 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
ResetRuntimeTokenCache()
clearCompatCache()
w := cmd.OutOrStdout()
postLoginSelector := authpkg.TokenProfileSelector(tokenData)
if tokenData != nil && strings.TrimSpace(tokenData.CorpID) != "" && strings.TrimSpace(tokenData.UserID) == "" {
if profiles, loadErr := authLoadProfiles(configDir); loadErr == nil && profiles != nil {
for i := range profiles.Profiles {
profile := profiles.Profiles[i]
if strings.TrimSpace(profile.CorpID) == strings.TrimSpace(tokenData.CorpID) && strings.TrimSpace(profile.UserID) == "" {
postLoginSelector = profileCLISelector(profile, profiles)
break
}
}
}
}
runPostLoginAuthorization := func() error {
if !recommendAuthMode {
return nil
}
restoreProfile := replaceRuntimeProfile(authpkg.TokenProfileSelector(tokenData))
restoreProfile := replaceRuntimeProfile(postLoginSelector)
defer restoreProfile()
recommendScopeMode := pat.LoginRecommendScopeRecommended
var initialPlan *pat.LoginRecommendPlan
@@ -287,7 +307,7 @@ var (
migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK
authMigrateTarget = func(cmd *cobra.Command) (string, error) { return cmd.Flags().GetString("to") }
authRunForm = (*huh.Form).Run
authSaveTokenData = authpkg.SaveTokenData
authSaveTokenData = authpkg.SaveLoginTokenData
authSaveAppConfig = authpkg.SaveAppConfig
authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, ctx context.Context) (*authpkg.TokenData, error) {
return provider.Login(ctx)
@@ -494,7 +514,9 @@ func newAuthStatusCommand() *cobra.Command {
if selected == nil {
return apperrors.NewValidation(fmt.Sprintf("profile %q not found", profileSelector))
}
profileSelector = authpkg.ProfileSelector(*selected)
if strings.TrimSpace(selected.UserID) != "" {
profileSelector = authpkg.ProfileSelector(*selected)
}
}
restoreProfile := pushRuntimeProfile(profileSelector)
defer restoreProfile()
@@ -522,7 +544,11 @@ func newAuthStatusCommand() *cobra.Command {
_ = authDeleteTokenData(configDir)
} else if tokenData != nil {
refreshFailure = refreshErr
_ = authMarkProfileStatus(configDir, authpkg.TokenProfileSelector(tokenData), authpkg.ProfileStatusExpired)
markSelector := profileSelector
if markSelector == "" {
markSelector = authpkg.StableTokenProfileSelector(configDir, tokenData)
}
_ = authMarkProfileStatus(configDir, markSelector, authpkg.ProfileStatusExpired)
}
}
if refreshFailure == nil && authStatusAuthenticated(tokenData) {
@@ -652,7 +678,14 @@ func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector
}
stableSelector := selected.CorpID
if exact {
stableSelector = authpkg.ProfileSelector(*selected)
if strings.TrimSpace(selected.UserID) == "" {
// A blank historical profile can coexist with exact accounts in the
// same organization. Preserve the exact local-name selector; reducing
// it to corpId would log out the entire organization.
stableSelector = strings.TrimSpace(selector)
} else {
stableSelector = authpkg.ProfileSelector(*selected)
}
if data, loadErr := authLoadTokenForProfile(configDir, stableSelector); loadErr == nil {
_ = authRevokeTokenForData(ctx, data)
}
@@ -661,7 +694,7 @@ func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector
if profile.CorpID != selected.CorpID {
continue
}
if data, tokenErr := authLoadTokenForProfile(configDir, authpkg.ProfileSelector(profile)); tokenErr == nil {
if data, tokenErr := authLoadTokenForProfile(configDir, profileCLISelector(profile, cfg)); tokenErr == nil {
_ = authRevokeTokenForData(ctx, data)
}
}
@@ -687,7 +720,7 @@ func logoutAllProfiles(_ *cobra.Command, ctx context.Context, configDir string)
_ = authRevokeToken(ctx)
} else {
for _, profile := range cfg.Profiles {
if data, tokenErr := authLoadTokenForProfile(configDir, authpkg.ProfileSelector(profile)); tokenErr == nil {
if data, tokenErr := authLoadTokenForProfile(configDir, profileCLISelector(profile, cfg)); tokenErr == nil {
_ = authRevokeTokenForData(ctx, data)
}
}
@@ -1206,7 +1239,7 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
yes, _ = cmd.Root().PersistentFlags().GetBool("yes")
profileSelector, _ = cmd.Root().PersistentFlags().GetString("profile")
}
targetCorpID, err := resolveAuthLoginTargetCorpID(defaultConfigDir(), profileSelector)
targetCorpID, historyProfileSelector, historyProfileSelectorExplicit, err := resolveAuthLoginTarget(defaultConfigDir(), profileSelector)
if err != nil {
return authLoginConfig{}, err
}
@@ -1221,15 +1254,18 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
"flow", flow,
"profile_selector", strings.TrimSpace(profileSelector),
"target_corp_id", targetCorpID,
"history_profile_selector", historyProfileSelector,
"recommend", recommend,
)
return authLoginConfig{
Token: strings.TrimSpace(token),
Force: force,
Device: device,
Recommend: recommend,
Yes: yes,
TargetCorpID: targetCorpID,
Token: strings.TrimSpace(token),
Force: force,
Device: device,
Recommend: recommend,
Yes: yes,
TargetCorpID: targetCorpID,
HistoryProfileSelector: historyProfileSelector,
HistoryProfileSelectorExplicit: historyProfileSelectorExplicit,
}, nil
}
@@ -1238,17 +1274,57 @@ func authLoginForcesAuthorization(_ authLoginConfig) bool {
}
func resolveAuthLoginTargetCorpID(configDir, selector string) (string, error) {
targetCorpID, _, _, err := resolveAuthLoginTarget(configDir, selector)
return targetCorpID, err
}
// resolveAuthLoginTarget keeps the authorization target separate from the
// local identity hint used only when contact cannot resolve the logged-in
// account. An implicit current profile must never constrain a fresh OAuth
// authorization to that profile's organization.
func resolveAuthLoginTarget(configDir, selector string) (targetCorpID, historySelector string, explicit bool, err error) {
selector = strings.TrimSpace(selector)
if selector == "" {
return "", nil
if profile, resolveErr := authResolveProfile(configDir, ""); resolveErr == nil && profile != nil {
return "", authLoginHistorySelector(configDir, profile), false, nil
}
return "", "", false, nil
}
if profile, err := authResolveProfile(configDir, selector); err == nil && profile != nil {
return strings.TrimSpace(profile.CorpID), nil
historySelector := authLoginHistorySelector(configDir, profile)
_, _, identityExact := authpkg.ParseIdentitySelector(selector)
if selector != strings.TrimSpace(profile.CorpID) && selector != strings.TrimSpace(profile.CorpName) {
identityExact = true
}
return strings.TrimSpace(profile.CorpID), historySelector, identityExact, nil
}
if _, _, exact := authpkg.ParseIdentitySelector(selector); exact || strings.Contains(selector, ":") {
return "", "", true, apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector))
}
if strings.HasPrefix(selector, "ding") {
return selector, nil
// A known organization that failed resolution is ambiguous (for
// example, two local accounts without an org-current pointer), not a
// request to invent a new corpId.
if cfg, loadErr := authLoadProfiles(configDir); loadErr == nil && cfg != nil {
for i := range cfg.Profiles {
if strings.TrimSpace(cfg.Profiles[i].CorpID) == selector {
return "", "", true, apperrors.NewValidation(fmt.Sprintf("profile %q is ambiguous; use an exact corpId:userId selector", selector))
}
}
}
return selector, "", false, nil
}
return "", apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector))
return "", "", true, apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector))
}
func authLoginHistorySelector(configDir string, profile *authpkg.Profile) string {
if profile == nil {
return ""
}
if cfg, err := authLoadProfiles(configDir); err == nil && cfg != nil {
return authpkg.ProfileSelectionSelector(*profile, cfg)
}
return authpkg.ProfileSelector(*profile)
}
type contactProfileIdentity struct {
@@ -1258,12 +1334,23 @@ type contactProfileIdentity struct {
UserName string
}
type authLoginHistoryHint struct {
Selector string
Explicit bool
}
type tokenOverrideToolCaller interface {
CallToolWithToken(ctx context.Context, token, productID, toolName string, args map[string]any) (*edition.ToolResult, error)
}
func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edition.ToolCaller, data *authpkg.TokenData) error {
if caller == nil || data == nil {
func enrichAuthLoginProfileFromContact(
ctx context.Context,
configDir string,
caller edition.ToolCaller,
data *authpkg.TokenData,
hints ...authLoginHistoryHint,
) error {
if data == nil {
return nil
}
corpID := strings.TrimSpace(data.CorpID)
@@ -1288,6 +1375,27 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi
)
return nil
}
hint := authLoginHistoryHint{}
if len(hints) > 0 {
hint = hints[0]
}
tryHistory := func() bool {
reused, historyErr := enrichAuthLoginProfileFromHistory(configDir, data, hint)
if historyErr != nil {
logging.AuthDebug(
"auth.login.identity.history.error",
"corp_id", corpID,
"error", historyErr,
)
}
return reused
}
if caller == nil {
if strings.TrimSpace(data.UserID) == "" {
tryHistory()
}
return nil
}
var (
result *edition.ToolResult
@@ -1297,7 +1405,7 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi
result, err = tokenCaller.CallToolWithToken(ctx, data.AccessToken, "contact", "get_current_user_profile", nil)
} else {
if strings.TrimSpace(data.UserID) == "" {
return fmt.Errorf("login identity lookup requires an in-memory token override")
tryHistory()
}
return nil
}
@@ -1311,11 +1419,15 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi
if strings.TrimSpace(data.UserID) != "" {
return nil
}
return err
tryHistory()
return nil
}
identity, ok := contactProfileIdentityFromToolResult(result)
identity, ok := contactProfileIdentityFromToolResult(result, corpID)
if !ok {
logging.AuthDebug("auth.login.identity.lookup.empty", "corp_id", corpID)
if strings.TrimSpace(data.UserID) == "" {
tryHistory()
}
return nil
}
logging.AuthDebug(
@@ -1327,19 +1439,42 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi
"corp_name", strings.TrimSpace(identity.CorpName),
)
if identity.CorpID != "" && identity.CorpID != corpID {
return fmt.Errorf("contact profile corpId %q does not match login corpId %q", identity.CorpID, corpID)
logging.AuthDebug(
"auth.login.identity.lookup.mismatch",
"login_corp_id", corpID,
"contact_corp_id", strings.TrimSpace(identity.CorpID),
)
if strings.TrimSpace(data.UserID) == "" {
tryHistory()
}
return nil
}
updated := *data
exchangeUserID := strings.TrimSpace(data.UserID)
if identity.CorpName != "" {
updated.CorpName = identity.CorpName
}
if identity.UserID != "" {
if exchangeUserID == "" && identity.UserID != "" {
updated.UserID = identity.UserID
}
if identity.UserName != "" {
if identity.UserName != "" && (exchangeUserID == "" || identity.UserID == "" || identity.UserID == exchangeUserID) {
updated.UserName = identity.UserName
}
if strings.TrimSpace(updated.UserID) == "" {
reused, historyErr := enrichAuthLoginProfileFromHistory(configDir, &updated, hint)
if historyErr != nil {
logging.AuthDebug(
"auth.login.identity.history.error",
"corp_id", corpID,
"error", historyErr,
)
}
if reused {
*data = updated
return nil
}
}
if updated.CorpName == data.CorpName && updated.UserID == data.UserID && updated.UserName == data.UserName {
logging.AuthDebug(
"auth.login.identity.resolved",
@@ -1361,7 +1496,144 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi
return nil
}
func contactProfileIdentityFromToolResult(result *edition.ToolResult) (contactProfileIdentity, bool) {
// enrichAuthLoginProfileFromHistory recovers display metadata when the contact
// service cannot describe an external-worker account. Historical profile
// selection is never proof of the user who completed a fresh authorization:
// only the token exchange or contact service may supply UserID.
//
// An explicit profile remains useful as a storage/selection hint. Keeping it in
// LegacyOrgScopedProfile prevents the login from switching the process-global
// current profile while SaveTokenData publishes the UID-less credential to the
// unresolved organization slot. A historical blank profile is updated in
// place; an exact historical profile and its token remain untouched.
func enrichAuthLoginProfileFromHistory(configDir string, data *authpkg.TokenData, hints ...authLoginHistoryHint) (bool, error) {
if data == nil || strings.TrimSpace(data.UserID) != "" {
return false, nil
}
corpID := strings.TrimSpace(data.CorpID)
if corpID == "" {
return false, nil
}
cfg, err := authLoadProfiles(configDir)
if err != nil {
return false, err
}
if cfg == nil {
return false, nil
}
sameCorp := make([]*authpkg.Profile, 0, len(cfg.Profiles))
for i := range cfg.Profiles {
profile := &cfg.Profiles[i]
if strings.TrimSpace(profile.CorpID) != corpID {
continue
}
sameCorp = append(sameCorp, profile)
}
if len(sameCorp) == 0 {
return false, nil
}
hint := authLoginHistoryHint{}
if len(hints) > 0 {
hint = hints[0]
}
var candidate *authpkg.Profile
if hint.Explicit {
candidate = historicalProfileForSelector(corpID, hint.Selector, sameCorp)
if candidate == nil {
// An explicit account is a hard identity boundary. If that exact
// historical hint no longer matches the token's organization, do
// not silently substitute org-current, sole, or global-current.
return false, nil
}
} else if len(sameCorp) > 1 {
// Organization-current is a storage preference, not proof of which user
// completed a fresh authorization. With multiple accounts, only an exact
// user selection may be used when the token/contact response has no UID.
return false, nil
} else {
candidate = sameCorp[0]
}
updated := *data
if hint.Explicit {
updated.LegacyOrgScopedProfile = strings.TrimSpace(hint.Selector)
}
if strings.TrimSpace(updated.CorpName) == "" {
updated.CorpName = strings.TrimSpace(candidate.CorpName)
}
if strings.TrimSpace(updated.UserName) == "" {
updated.UserName = strings.TrimSpace(candidate.UserName)
}
*data = updated
logging.AuthDebug(
"auth.login.identity.resolved",
"source", "local_profile_history_display_only",
"corp_id", corpID,
"user_id", updated.UserID,
"user_name", updated.UserName,
"corp_name", updated.CorpName,
"identity_proven", false,
)
return true, nil
}
func historicalProfileForSelector(corpID, selector string, profiles []*authpkg.Profile) *authpkg.Profile {
selector = strings.TrimSpace(selector)
if selector == "" {
return nil
}
cfg := &authpkg.ProfilesConfig{Profiles: make([]authpkg.Profile, 0, len(profiles))}
for _, profile := range profiles {
if profile != nil {
cfg.Profiles = append(cfg.Profiles, *profile)
}
}
var stableMatch *authpkg.Profile
for _, profile := range profiles {
if profile == nil || strings.TrimSpace(profile.CorpID) != strings.TrimSpace(corpID) ||
authpkg.ProfileSelectionSelector(*profile, cfg) != selector {
continue
}
if stableMatch != nil {
return nil
}
stableMatch = profile
}
if stableMatch != nil {
return stableMatch
}
if selectedCorpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact {
if strings.TrimSpace(selectedCorpID) != strings.TrimSpace(corpID) {
return nil
}
for _, profile := range profiles {
if profile != nil && strings.TrimSpace(profile.UserID) == strings.TrimSpace(userID) {
return profile
}
}
return nil
}
var named *authpkg.Profile
for _, profile := range profiles {
if profile == nil || strings.TrimSpace(profile.Name) != selector {
continue
}
if named != nil {
return nil
}
named = profile
}
if named != nil {
return named
}
if selector == strings.TrimSpace(corpID) && len(profiles) == 1 {
return profiles[0]
}
return nil
}
func contactProfileIdentityFromToolResult(result *edition.ToolResult, expectedCorpIDs ...string) (contactProfileIdentity, bool) {
if result == nil {
return contactProfileIdentity{}, false
}
@@ -1369,14 +1641,14 @@ func contactProfileIdentityFromToolResult(result *edition.ToolResult) (contactPr
if strings.TrimSpace(block.Text) == "" {
continue
}
if identity, ok := contactProfileIdentityFromJSON([]byte(block.Text)); ok {
if identity, ok := contactProfileIdentityFromJSON([]byte(block.Text), expectedCorpIDs...); ok {
return identity, true
}
}
return contactProfileIdentity{}, false
}
func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool) {
func contactProfileIdentityFromJSON(data []byte, expectedCorpIDs ...string) (contactProfileIdentity, bool) {
var payload struct {
Result []struct {
OrgEmployeeModel struct {
@@ -1396,14 +1668,40 @@ func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool)
if len(payload.Result) == 0 {
return contactProfileIdentity{}, false
}
org := payload.Result[0].OrgEmployeeModel
identity := contactProfileIdentity{
CorpID: strings.TrimSpace(org.CorpID),
CorpName: strings.TrimSpace(org.OrgName),
UserID: firstNonEmptyString(org.UserID, org.UserIDLower, org.OrgUserID),
UserName: firstNonEmptyString(org.OrgUserName, org.Name),
identities := make([]contactProfileIdentity, 0, len(payload.Result))
for i := range payload.Result {
org := payload.Result[i].OrgEmployeeModel
identity := contactProfileIdentity{
CorpID: strings.TrimSpace(org.CorpID),
CorpName: strings.TrimSpace(org.OrgName),
UserID: firstNonEmptyString(org.UserID, org.UserIDLower, org.OrgUserID),
UserName: firstNonEmptyString(org.OrgUserName, org.Name),
}
if identity.CorpID != "" || identity.CorpName != "" || identity.UserID != "" || identity.UserName != "" {
identities = append(identities, identity)
}
}
return identity, identity.CorpID != "" || identity.CorpName != "" || identity.UserID != "" || identity.UserName != ""
if len(identities) == 0 {
return contactProfileIdentity{}, false
}
expectedCorpID := ""
if len(expectedCorpIDs) > 0 {
expectedCorpID = strings.TrimSpace(expectedCorpIDs[0])
}
if expectedCorpID != "" {
for _, identity := range identities {
if identity.CorpID == expectedCorpID {
return identity, true
}
}
// Older contact responses omit corpId. A single result is still
// unambiguous; multiple organization records without a target match
// must fall back to local history instead of choosing result[0].
if len(payload.Result) != 1 {
return contactProfileIdentity{}, false
}
}
return identities[0], true
}
func firstNonEmptyString(values ...string) string {
+596 -7
View File
@@ -262,7 +262,10 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true"}); err == nil {
t.Fatal("device error should propagate")
}
authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) {
authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, _ context.Context) (*authpkg.TokenData, error) {
if provider.IdentityEnricher == nil {
t.Error("device login missing shared identity enricher")
}
return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil
}
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "no-browser": "true"}); err != nil {
@@ -275,7 +278,10 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
if _, _, err := authCoverageRunLogin(t, nil, "table", true, nil); err == nil {
t.Fatal("oauth error should propagate")
}
authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) {
authOAuthLogin = func(provider *authpkg.OAuthProvider, _ context.Context, _ bool) (*authpkg.TokenData, error) {
if provider.IdentityEnricher == nil {
t.Error("OAuth login missing shared identity enricher")
}
return &authpkg.TokenData{
AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour), RefreshToken: "r", RefreshExpAt: time.Now().Add(48 * time.Hour),
CorpName: "Corp", CorpID: "ding1", UserName: "User", UserID: "u",
@@ -356,8 +362,8 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{}, complete); err != nil {
t.Fatal(err)
}
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{err: errors.New("call")}, &authpkg.TokenData{CorpID: "ding"}); err == nil {
t.Fatal("caller error should propagate")
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{err: errors.New("call")}, &authpkg.TokenData{CorpID: "ding"}); err != nil {
t.Fatalf("contact failure must remain best effort: %v", err)
}
if err := enrichAuthLoginProfileFromContact(
ctx,
@@ -374,8 +380,8 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
}
}
mismatch := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"other"}}]}`}}}}
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", mismatch, &authpkg.TokenData{CorpID: "ding", AccessToken: "token"}); err == nil {
t.Fatal("corp mismatch should fail")
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", mismatch, &authpkg.TokenData{CorpID: "ding", AccessToken: "token"}); err != nil {
t.Fatalf("contact corp mismatch must remain best effort: %v", err)
}
same := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding","orgName":"Corp","userid":"u","name":"User"}}]}`}}}}
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, complete); err != nil {
@@ -390,6 +396,25 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, data); err != nil || data.CorpName != "Corp" || data.UserID != "u" {
t.Fatalf("enriched = %#v, %v", data, err)
}
known := &authpkg.TokenData{CorpID: "ding", UserID: "exchange-user", AccessToken: "token"}
differentContactUser := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding","orgName":"Corp","userid":"other-user","name":"Other User"}}]}`}}}}
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", differentContactUser, known); err != nil {
t.Fatal(err)
}
if known.UserID != "exchange-user" || known.UserName != "" || known.CorpName != "Corp" {
t.Fatalf("token-exchange identity was overwritten: %#v", known)
}
multiOrg := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"other","userid":"other-user"}},{"orgEmployeeModel":{"corpId":"ding","orgName":"Target Corp","userid":"target-user","name":"Target User"}}]}`}}}}
multiOrgData := &authpkg.TokenData{CorpID: "ding", AccessToken: "token"}
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", multiOrg, multiOrgData); err != nil || multiOrgData.UserID != "target-user" || multiOrgData.CorpName != "Target Corp" {
t.Fatalf("multi-org contact selection = %#v, %v", multiOrgData, err)
}
if _, ok := contactProfileIdentityFromJSON(
[]byte(`{"result":[{"orgEmployeeModel":{"corpId":"other-a","userid":"user-a"}},{"orgEmployeeModel":{"corpId":"other-b","userid":"user-b"}}]}`),
"ding",
); ok {
t.Fatal("multiple nonmatching organizations must not select an arbitrary contact identity")
}
if _, ok := contactProfileIdentityFromToolResult(nil); ok {
t.Fatal("nil result should not parse")
}
@@ -398,6 +423,570 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
}
}
func TestCrossPlatformCoverageContactFailureReusesOnlySameCorpHistoricalDisplayMetadata(t *testing.T) {
configDir := t.TempDir()
if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{
Version: 1,
Profiles: []authpkg.Profile{{
CorpID: "ding_ecological_worker",
CorpName: "Historical Corp",
UserID: "external-user",
UserName: "Historical Worker",
}},
}); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
for _, tc := range []struct {
name string
caller edition.ToolCaller
wantCorp string
}{
{
name: "contact business error",
caller: &authCoverageCaller{err: apperrors.NewAPI(
"business error: success=false",
apperrors.WithReason("business_error"),
)},
wantCorp: "Fresh Corp",
},
{
name: "contact has no identity",
caller: &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"success":false}`}}}},
wantCorp: "Fresh Corp",
},
{
name: "contact identity is missing user id",
caller: &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{
Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding_ecological_worker","orgName":"Contact Corp"}}]}`,
}}}},
wantCorp: "Contact Corp",
},
{
name: "ordinary contact error",
caller: &authCoverageCaller{err: errors.New("network failure")},
wantCorp: "Fresh Corp",
},
{
name: "other contact business error",
caller: &authCoverageCaller{err: apperrors.NewAPI(
"permission denied",
apperrors.WithReason("business_error"),
)},
wantCorp: "Fresh Corp",
},
{
name: "contact caller unavailable",
caller: nil,
wantCorp: "Fresh Corp",
},
{
name: "contact returns another organization",
caller: &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{
Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding_other","userid":"other-user"}}]}`,
}}}},
wantCorp: "Fresh Corp",
},
} {
t.Run(tc.name, func(t *testing.T) {
data := &authpkg.TokenData{
AccessToken: "new-access",
RefreshToken: "new-refresh",
CorpID: "ding_ecological_worker",
CorpName: "Fresh Corp",
}
if err := enrichAuthLoginProfileFromContact(context.Background(), configDir, tc.caller, data); err != nil {
t.Fatalf("contact failure blocked historical identity recovery: %v", err)
}
if data.UserID != "" || data.UserName != "Historical Worker" {
t.Fatalf("historical metadata supplied UID evidence: %#v", data)
}
if data.CorpName != tc.wantCorp {
t.Fatalf("corp name = %q, want %q", data.CorpName, tc.wantCorp)
}
if data.AccessToken != "new-access" || data.RefreshToken != "new-refresh" {
t.Fatalf("new token material was changed: %#v", data)
}
})
}
}
func TestCrossPlatformCoverageContactFailureDoesNotGuessHistoricalIdentity(t *testing.T) {
businessErr := apperrors.NewAPI(
"business error: success=false",
apperrors.WithReason("business_error"),
)
for _, tc := range []struct {
name string
corpID string
profiles []authpkg.Profile
callErr error
}{
{
name: "same corp has two identities",
corpID: "ding_ecological_worker",
profiles: []authpkg.Profile{
{CorpID: "ding_ecological_worker", UserID: "external-user"},
{CorpID: "ding_ecological_worker", UserID: "external-user-b"},
},
callErr: businessErr,
},
{
name: "same corp has one identity and one blank profile",
corpID: "ding_ecological_worker",
profiles: []authpkg.Profile{
{CorpID: "ding_ecological_worker", UserID: "external-user"},
{CorpID: "ding_ecological_worker"},
},
callErr: businessErr,
},
{
name: "identity belongs to another corp",
corpID: "ding_ecological_worker",
profiles: []authpkg.Profile{
{CorpID: "ding_other", UserID: "external-user"},
},
callErr: businessErr,
},
{
name: "no historical identity",
corpID: "ding_ecological_worker",
callErr: businessErr,
},
} {
t.Run(tc.name, func(t *testing.T) {
configDir := t.TempDir()
if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{Version: 2, Profiles: tc.profiles}); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
data := &authpkg.TokenData{AccessToken: "new-access", CorpID: tc.corpID}
err := enrichAuthLoginProfileFromContact(
context.Background(),
configDir,
&authCoverageCaller{err: tc.callErr},
data,
)
if err != nil {
t.Fatalf("contact failure must not block unresolved legacy login: %v", err)
}
if data.UserID != "" {
t.Fatalf("ambiguous/cross-corp identity was reused: %#v", data)
}
})
}
}
func TestCrossPlatformCoverageContactHistoryFallbackEdges(t *testing.T) {
for _, data := range []*authpkg.TokenData{
nil,
{UserID: "known"},
{},
} {
reused, err := enrichAuthLoginProfileFromHistory(t.TempDir(), data)
if reused || err != nil {
t.Fatalf("ineligible history fallback = %v, %v", reused, err)
}
}
configDir := t.TempDir()
if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{
Version: 2,
Profiles: []authpkg.Profile{{
CorpID: "ding_external",
CorpName: "Historical Corp",
UserID: "external-user",
UserName: "Historical Worker",
}},
}); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
data := &authpkg.TokenData{CorpID: "ding_external"}
reused, err := enrichAuthLoginProfileFromHistory(configDir, data)
if err != nil || !reused {
t.Fatalf("history fallback = %v, %v", reused, err)
}
if data.CorpName != "Historical Corp" || data.UserName != "Historical Worker" || data.UserID != "" {
t.Fatalf("history metadata = %#v", data)
}
corruptDir := t.TempDir()
if err := os.Mkdir(authpkg.ProfilesPath(corruptDir), 0o700); err != nil {
t.Fatalf("create unreadable profiles path: %v", err)
}
if reused, err := enrichAuthLoginProfileFromHistory(corruptDir, &authpkg.TokenData{CorpID: "ding_external"}); reused || err == nil {
t.Fatalf("corrupt history fallback = %v, %v; want load error", reused, err)
}
businessErr := apperrors.NewAPI(
"business error: success=false",
apperrors.WithReason("business_error"),
)
for _, tc := range []struct {
name string
caller *authCoverageCaller
}{
{
name: "contact business error",
caller: &authCoverageCaller{err: businessErr},
},
{
name: "contact has no identity",
caller: &authCoverageCaller{result: &edition.ToolResult{}},
},
{
name: "contact identity is missing user id",
caller: &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{
Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding_external"}}]}`,
}}}},
},
} {
t.Run(tc.name, func(t *testing.T) {
err := enrichAuthLoginProfileFromContact(
context.Background(),
corruptDir,
tc.caller,
&authpkg.TokenData{CorpID: "ding_external", AccessToken: "new-access"},
)
if err != nil {
t.Fatalf("best-effort contact/history lookup blocked login: %v", err)
}
})
}
}
func TestCrossPlatformCoverageAuthLoginConfigPreservesHistoryIdentityHint(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldResolve := authResolveProfile
oldLoad := authLoadProfiles
t.Cleanup(func() {
authResolveProfile = oldResolve
authLoadProfiles = oldLoad
})
explicit := &authpkg.Profile{CorpID: "ding_same", UserID: "user_2", Name: "second"}
current := &authpkg.Profile{CorpID: "ding_current", UserID: "current_user"}
authResolveProfile = func(_ string, selector string) (*authpkg.Profile, error) {
switch selector {
case "ding_same:user_2":
clone := *explicit
return &clone, nil
case "external-worker":
return &authpkg.Profile{Name: "external-worker", CorpID: "ding_external"}, nil
case "":
clone := *current
return &clone, nil
default:
return nil, errors.New("missing")
}
}
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{}, nil
}
cmd := newAuthLoginCommand(nil)
root, _, _ := authCoverageRoot(cmd, "table", true)
if err := root.PersistentFlags().Set("profile", "ding_same:user_2"); err != nil {
t.Fatal(err)
}
cfg, err := resolveAuthLoginConfig(cmd)
if err != nil {
t.Fatal(err)
}
if cfg.TargetCorpID != "ding_same" || cfg.HistoryProfileSelector != "ding_same:user_2" || !cfg.HistoryProfileSelectorExplicit {
t.Fatalf("explicit login config = %#v", cfg)
}
if target, hint, exact, err := resolveAuthLoginTarget("cfg", "external-worker"); err != nil ||
target != "ding_external" || hint != "ding_external" || !exact {
t.Fatalf("blank-userId profile target = %q/%q/%v, %v", target, hint, exact, err)
}
if err := root.PersistentFlags().Set("profile", ""); err != nil {
t.Fatal(err)
}
cfg, err = resolveAuthLoginConfig(cmd)
if err != nil {
t.Fatal(err)
}
if cfg.TargetCorpID != "" || cfg.HistoryProfileSelector != "ding_current:current_user" || cfg.HistoryProfileSelectorExplicit {
t.Fatalf("implicit login config constrained authorization target: %#v", cfg)
}
if _, _, _, err := resolveAuthLoginTarget("cfg", "ding_same:missing"); err == nil {
t.Fatal("missing exact profile must not be reinterpreted as a corpId")
}
if target, hint, explicitHint, err := resolveAuthLoginTarget("cfg", "ding_new"); err != nil || target != "ding_new" || hint != "" || explicitHint {
t.Fatalf("new organization target = %q/%q/%v, %v", target, hint, explicitHint, err)
}
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{
{CorpID: "ding_ambiguous", UserID: "user_1"},
{CorpID: "ding_ambiguous", UserID: "user_2"},
}}, nil
}
if _, _, _, err := resolveAuthLoginTarget("cfg", "ding_ambiguous"); err == nil {
t.Fatal("ambiguous known organization must require an exact profile")
}
}
func TestCrossPlatformCoverageOAuthAndDeviceKeepFreshUnknownIdentityIsolatedFromExactHistory(t *testing.T) {
oldResolve := authResolveProfile
oldLoad := authLoadProfiles
oldDevice := authDeviceLogin
oldOAuth := authOAuthLogin
oldInteractive := authLoginInteractiveTerminal
t.Cleanup(func() {
authResolveProfile = oldResolve
authLoadProfiles = oldLoad
authDeviceLogin = oldDevice
authOAuthLogin = oldOAuth
authLoginInteractiveTerminal = oldInteractive
})
authResolveProfile = authpkg.ResolveProfile
authLoadProfiles = authpkg.LoadProfiles
authLoginInteractiveTerminal = func() bool { return false }
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
for _, flow := range []string{"oauth", "device"} {
t.Run(flow, func(t *testing.T) {
configDir := t.TempDir()
keychainDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, keychainDir)
// StorageDirEnv isolates file-backed keychains, while Windows uses
// DPAPI-protected HKCU values. Give every flow its own namespace so
// OAuth/device fixtures cannot leak into each other or later tests.
t.Setenv(keychain.TestNamespaceEnv, keychainDir)
t.Cleanup(func() {
if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil {
t.Errorf("clean auth keychain fixture: %v", err)
}
})
authpkg.SetRuntimeProfile("")
const (
corpID = "ding_same"
historicalUID = "user_a"
exactSelector = corpID + ":" + historicalUID
)
oldToken := &authpkg.TokenData{
AccessToken: "old-user-a-access",
RefreshToken: "old-user-a-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: corpID,
CorpName: "Same Corp",
UserID: historicalUID,
UserName: "Historical User A",
}
if err := authpkg.SaveTokenData(configDir, oldToken); err != nil {
t.Fatalf("persist historical exact identity: %v", err)
}
caller := &authCoverageCaller{err: errors.New("contact unavailable")}
var enriched *authpkg.TokenData
freshToken := func() *authpkg.TokenData {
return &authpkg.TokenData{
AccessToken: "fresh-user-b-access-" + flow,
RefreshToken: "fresh-user-b-refresh-" + flow,
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: corpID,
}
}
persistUnknown := func(ctx context.Context, identityEnricher func(context.Context, *authpkg.TokenData) error) (*authpkg.TokenData, error) {
if identityEnricher == nil {
return nil, errors.New("missing identity enricher")
}
data := freshToken()
if err := identityEnricher(ctx, data); err != nil {
return nil, err
}
enriched = data
if data.UserID != "" {
return nil, fmt.Errorf("historical profile supplied unproven userId %q", data.UserID)
}
if err := authpkg.SaveTokenData(configDir, data); err != nil {
return nil, err
}
return data, nil
}
flags := map[string]string{"profile": exactSelector}
switch flow {
case "device":
flags["device"] = "true"
authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, ctx context.Context) (*authpkg.TokenData, error) {
return persistUnknown(ctx, provider.IdentityEnricher)
}
case "oauth":
authOAuthLogin = func(provider *authpkg.OAuthProvider, ctx context.Context, _ bool) (*authpkg.TokenData, error) {
if provider.TargetCorpID != corpID {
return nil, fmt.Errorf("OAuth target corp = %q", provider.TargetCorpID)
}
return persistUnknown(ctx, provider.IdentityEnricher)
}
}
if _, _, err := authCoverageRunLogin(t, caller, "table", true, flags); err != nil {
t.Fatalf("%s login with unresolved fresh identity: %v", flow, err)
}
if enriched == nil || enriched.UserID != "" ||
enriched.LegacyOrgScopedProfile != exactSelector ||
enriched.CorpName != "Same Corp" ||
enriched.UserName != "Historical User A" {
t.Fatalf("%s history hint became identity evidence: %#v", flow, enriched)
}
historical, err := authpkg.LoadTokenDataForProfile(configDir, exactSelector)
if err != nil {
t.Fatalf("load historical exact identity: %v", err)
}
if historical.AccessToken != oldToken.AccessToken || historical.UserID != historicalUID {
t.Fatalf("historical exact slot was overwritten: %#v", historical)
}
profiles, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("load profiles: %v", err)
}
var unresolved *authpkg.Profile
for i := range profiles.Profiles {
profile := &profiles.Profiles[i]
if profile.CorpID == corpID && profile.UserID == "" {
unresolved = profile
break
}
}
if unresolved == nil {
t.Fatalf("fresh UID-less token did not create an unresolved profile: %#v", profiles.Profiles)
}
unresolvedSelector := authpkg.ProfileSelectionSelector(*unresolved, profiles)
if unresolvedSelector == "" || unresolvedSelector == exactSelector {
t.Fatalf("unresolved selector = %q", unresolvedSelector)
}
fresh, err := authpkg.LoadTokenDataForProfile(configDir, unresolvedSelector)
if err != nil {
t.Fatalf("load fresh unresolved identity: %v", err)
}
if fresh.AccessToken != "fresh-user-b-access-"+flow || fresh.UserID != "" {
t.Fatalf("fresh token was not isolated in unresolved org slot: %#v", fresh)
}
})
}
}
func TestCrossPlatformCoverageHistoricalIdentityPriorityAndBlankUserID(t *testing.T) {
oldLoad := authLoadProfiles
t.Cleanup(func() { authLoadProfiles = oldLoad })
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
if reused, err := enrichAuthLoginProfileFromHistory("cfg", &authpkg.TokenData{CorpID: "ding_same"}); reused || err != nil {
t.Fatalf("nil history registry = reused=%v err=%v", reused, err)
}
cfg := &authpkg.ProfilesConfig{
CurrentProfile: "ding_same:user_1",
OrgCurrentProfiles: map[string]string{
"ding_same": "ding_same:user_2",
},
Profiles: []authpkg.Profile{
{CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_1", UserName: "First"},
{CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_2", UserName: "Second"},
},
}
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return cfg, nil }
explicitData := &authpkg.TokenData{CorpID: "ding_same"}
reused, err := enrichAuthLoginProfileFromHistory("cfg", explicitData, authLoginHistoryHint{Selector: "ding_same:user_1", Explicit: true})
if err != nil || !reused || explicitData.UserID != "" ||
explicitData.LegacyOrgScopedProfile != "ding_same:user_1" ||
explicitData.CorpName != "Same Corp" || explicitData.UserName != "First" {
t.Fatalf("explicit history selection = %#v, reused=%v err=%v", explicitData, reused, err)
}
mismatchedHintData := &authpkg.TokenData{CorpID: "ding_same"}
reused, err = enrichAuthLoginProfileFromHistory("cfg", mismatchedHintData, authLoginHistoryHint{Selector: "ding_other:user_9", Explicit: true})
if err != nil || reused || mismatchedHintData.UserID != "" {
t.Fatalf("cross-corp explicit hint reused another identity: %#v, reused=%v err=%v", mismatchedHintData, reused, err)
}
orgCurrentData := &authpkg.TokenData{CorpID: "ding_same"}
reused, err = enrichAuthLoginProfileFromHistory("cfg", orgCurrentData)
if err != nil || reused || orgCurrentData.UserID != "" {
t.Fatalf("implicit multi-account org-current was treated as identity proof: %#v, reused=%v err=%v", orgCurrentData, reused, err)
}
cfg.Profiles = []authpkg.Profile{cfg.Profiles[1]}
soleData := &authpkg.TokenData{CorpID: "ding_same"}
reused, err = enrichAuthLoginProfileFromHistory("cfg", soleData)
if err != nil || !reused || soleData.UserID != "" ||
soleData.CorpName != "Same Corp" || soleData.UserName != "Second" {
t.Fatalf("sole history selection = %#v, reused=%v err=%v", soleData, reused, err)
}
cfg.Profiles = []authpkg.Profile{
{CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_1", UserName: "First"},
{CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_2", UserName: "Second"},
}
cfg.OrgCurrentProfiles = nil
currentData := &authpkg.TokenData{CorpID: "ding_same"}
reused, err = enrichAuthLoginProfileFromHistory("cfg", currentData)
if err != nil || reused || currentData.UserID != "" {
t.Fatalf("implicit multi-account current was treated as identity proof: %#v, reused=%v err=%v", currentData, reused, err)
}
cfg.CurrentProfile = ""
ambiguousData := &authpkg.TokenData{CorpID: "ding_same"}
reused, err = enrichAuthLoginProfileFromHistory("cfg", ambiguousData)
if err != nil || reused || ambiguousData.UserID != "" {
t.Fatalf("ambiguous history selection = %#v, reused=%v err=%v", ambiguousData, reused, err)
}
cfg.Profiles = []authpkg.Profile{{
Name: "external-worker", CorpID: "ding_same", CorpName: "Legacy Corp", UserName: "Legacy Worker",
}}
blankData := &authpkg.TokenData{CorpID: "ding_same"}
reused, err = enrichAuthLoginProfileFromHistory("cfg", blankData, authLoginHistoryHint{Selector: "external-worker", Explicit: true})
if err != nil || !reused || blankData.UserID != "" || blankData.LegacyOrgScopedProfile != "external-worker" || blankData.CorpName != "Legacy Corp" || blankData.UserName != "Legacy Worker" {
t.Fatalf("blank-userId history selection = %#v, reused=%v err=%v", blankData, reused, err)
}
contactBlankData := &authpkg.TokenData{CorpID: "ding_same", AccessToken: "new-token"}
if err := enrichAuthLoginProfileFromContact(
context.Background(),
"cfg",
&authCoverageCaller{err: errors.New("contact unavailable")},
contactBlankData,
authLoginHistoryHint{Selector: "external-worker", Explicit: true},
); err != nil {
t.Fatalf("blank-userId history must keep contact best effort: %v", err)
}
if contactBlankData.LegacyOrgScopedProfile != "external-worker" {
t.Fatalf("blank-userId contact fallback did not authorize the historical organization slot: %#v", contactBlankData)
}
profiles := []*authpkg.Profile{
nil,
{Name: "duplicate", CorpID: "ding_same", UserID: "user_1"},
{Name: "duplicate", CorpID: "ding_same", UserID: "user_2"},
}
for _, tc := range []struct {
name string
selector string
profiles []*authpkg.Profile
want *authpkg.Profile
}{
{name: "empty selector", selector: "", profiles: profiles},
{name: "missing exact identity", selector: "ding_same:missing", profiles: profiles},
{name: "duplicate name", selector: "duplicate", profiles: profiles},
{name: "unmatched name", selector: "not-found", profiles: profiles},
{name: "sole organization selector", selector: "ding_same", profiles: profiles[1:2], want: profiles[1]},
} {
t.Run("selector "+tc.name, func(t *testing.T) {
if got := historicalProfileForSelector("ding_same", tc.selector, tc.profiles); got != tc.want {
t.Fatalf("historicalProfileForSelector(%q) = %#v, want %#v", tc.selector, got, tc.want)
}
})
}
}
func TestCrossPlatformCoverageAuthCoverageDefaultSeamClosures(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
@@ -748,7 +1337,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
if err := importCmd.RunE(badForce, nil); err == nil {
t.Fatal("invalid force flag should fail")
}
_, out, _ = authCoverageRoot(importCmd, "table", false)
_, _, _ = authCoverageRoot(importCmd, "table", false)
if err := importCmd.RunE(importCmd, nil); err == nil {
t.Fatal("missing input should fail")
}
+9
View File
@@ -195,6 +195,15 @@ func TestCrossPlatformCoverageAuthImportRejectsWindowsDPAPIBackend(t *testing.T)
}
t.Setenv("DWS_CONFIG_DIR", configDir)
t.Setenv(keychain.StorageDirEnv, keychainDir)
// Windows stores credentials in HKCU rather than StorageDirEnv. Use a
// fresh registry namespace so this zero-state assertion cannot inherit a
// token from an earlier test in the same package binary.
t.Setenv(keychain.TestNamespaceEnv, root)
t.Cleanup(func() {
if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil {
t.Errorf("clean import guard keychain fixture: %v", err)
}
})
importCmd := NewRootCommand()
importCmd.SetOut(&bytes.Buffer{})
@@ -0,0 +1,307 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"errors"
"io"
"strings"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestCrossPlatformCoverageAuthLoginUsesStableBlankProfileForPostLoginAuthorization(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldOAuth := authOAuthLogin
oldLoadProfiles := authLoadProfiles
oldRecommend := authRunLoginRecommend
oldInteractive := authLoginInteractiveTerminal
oldResolve := authResolveProfile
t.Cleanup(func() {
authOAuthLogin = oldOAuth
authLoadProfiles = oldLoadProfiles
authRunLoginRecommend = oldRecommend
authLoginInteractiveTerminal = oldInteractive
authResolveProfile = oldResolve
})
const corpID = "corp_post_login_blank"
cfg := &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{
{Name: "Fixture Organization", CorpID: corpID, CorpName: "Fixture Organization"},
{Name: "Exact Fixture", CorpID: corpID, CorpName: "Fixture Organization", UserID: "identity_exact"},
}}
wantSelector := authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg)
if wantSelector == "" || wantSelector == corpID {
t.Fatalf("blank selector = %q, want a stable account selector", wantSelector)
}
authResolveProfile = func(string, string) (*authpkg.Profile, error) {
return nil, errors.New("no implicit profile")
}
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return cfg, nil }
authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) {
return &authpkg.TokenData{
AccessToken: "new-access",
ExpiresAt: time.Now().Add(time.Hour),
CorpID: corpID,
}, nil
}
authLoginInteractiveTerminal = func() bool { return false }
seenSelector := ""
authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error {
seenSelector = authpkg.RuntimeProfile()
return nil
}
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"recommend": "true"}); err != nil {
t.Fatalf("blank-profile login error = %v", err)
}
if seenSelector != wantSelector {
t.Fatalf("post-login runtime selector = %q, want %q", seenSelector, wantSelector)
}
}
func TestCrossPlatformCoverageAuthStatusAndLogoutPreserveExactSelectors(t *testing.T) {
t.Run("status canonicalizes a known identity", func(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
const exactSelector = "corp_status_fixture:identity_status_fixture"
if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{
Version: 2,
Profiles: []authpkg.Profile{{
Name: "Status Fixture",
CorpID: "corp_status_fixture",
UserID: "identity_status_fixture",
}},
}); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
oldStatus := authOAuthStatus
t.Cleanup(func() { authOAuthStatus = oldStatus })
seenSelector := ""
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) {
seenSelector = authpkg.RuntimeProfile()
return &authpkg.TokenData{
AccessToken: "access",
ExpiresAt: time.Now().Add(time.Hour),
CorpID: "corp_status_fixture",
UserID: "identity_status_fixture",
}, nil
}
cmd := newAuthStatusCommand()
_, _, _ = authCoverageRoot(cmd, "table", false)
if err := cmd.Flags().Set("profile", " Status Fixture "); err != nil {
t.Fatal(err)
}
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("auth status error = %v", err)
}
if seenSelector != exactSelector {
t.Fatalf("status runtime selector = %q, want %q", seenSelector, exactSelector)
}
})
t.Run("logout keeps a blank local selector", func(t *testing.T) {
oldResolve := authResolveProfileDeletion
oldLoad := authLoadTokenForProfile
oldRevoke := authRevokeTokenForData
oldDelete := authDeleteProfileToken
t.Cleanup(func() {
authResolveProfileDeletion = oldResolve
authLoadTokenForProfile = oldLoad
authRevokeTokenForData = oldRevoke
authDeleteProfileToken = oldDelete
})
const selector = "legacy-external-worker"
authResolveProfileDeletion = func(string, string) (*authpkg.Profile, bool, error) {
return &authpkg.Profile{CorpID: "corp_logout_blank"}, true, nil
}
loadedSelector := ""
authLoadTokenForProfile = func(_ string, got string) (*authpkg.TokenData, error) {
loadedSelector = got
return &authpkg.TokenData{CorpID: "corp_logout_blank"}, nil
}
authRevokeTokenForData = func(context.Context, *authpkg.TokenData) error { return nil }
deletedSelector := ""
authDeleteProfileToken = func(_ string, got string) error {
deletedSelector = got
return nil
}
if err := logoutOneProfile(nil, context.Background(), "cfg", " "+selector+" "); err != nil {
t.Fatalf("logoutOneProfile() error = %v", err)
}
if loadedSelector != selector || deletedSelector != selector {
t.Fatalf("blank logout selectors = load %q delete %q, want %q", loadedSelector, deletedSelector, selector)
}
})
}
func TestCrossPlatformCoverageAuthHistorySelectorRemainingBranches(t *testing.T) {
if got := authLoginHistorySelector("cfg", nil); got != "" {
t.Fatalf("nil history selector = %q", got)
}
oldLoad := authLoadProfiles
t.Cleanup(func() { authLoadProfiles = oldLoad })
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return nil, errors.New("profiles unavailable")
}
profile := &authpkg.Profile{CorpID: "corp_history", UserID: "identity_history"}
if got := authLoginHistorySelector("cfg", profile); got != "corp_history:identity_history" {
t.Fatalf("history selector fallback = %q", got)
}
duplicateA := &authpkg.Profile{CorpID: "corp_history", UserID: "duplicate_identity"}
duplicateB := &authpkg.Profile{CorpID: "corp_history", UserID: "duplicate_identity"}
if got := historicalProfileForSelector(
"corp_history",
"corp_history:duplicate_identity",
[]*authpkg.Profile{duplicateA, duplicateB},
); got != nil {
t.Fatalf("duplicate stable identity selected %#v", got)
}
// Whitespace keeps the raw selector from matching the stable string while
// ParseIdentitySelector still resolves its components.
exactFallback := &authpkg.Profile{CorpID: "corp_history", UserID: "fallback_identity"}
if got := historicalProfileForSelector(
"corp_history",
"corp_history : fallback_identity",
[]*authpkg.Profile{exactFallback},
); got != exactFallback {
t.Fatalf("exact history fallback = %#v, want %#v", got, exactFallback)
}
}
func TestCrossPlatformCoverageProfileSwitchLegacyBlankAndNormalizedIdentityPointers(t *testing.T) {
t.Run("one legacy blank name", func(t *testing.T) {
profiles := []authpkg.Profile{
{Name: "Fixture Organization", CorpID: "corp_profile_fixture", CorpName: "Fixture Organization"},
{Name: "Exact Fixture", CorpID: "corp_profile_fixture", CorpName: "Fixture Organization", UserID: "identity_exact"},
}
cfg := &authpkg.ProfilesConfig{Profiles: profiles}
if got := profileSwitchProfileIndex(profiles, "Fixture Organization", cfg); got != 0 {
t.Fatalf("legacy blank profile index = %d, want 0", got)
}
})
t.Run("duplicate legacy names fall through to blank-name compatibility", func(t *testing.T) {
profiles := []authpkg.Profile{
{Name: "duplicate-legacy", CorpID: "corp_profile_fixture"},
{Name: "duplicate-legacy", CorpID: "corp_profile_fixture"},
}
cfg := &authpkg.ProfilesConfig{Profiles: profiles}
if got := profileSwitchProfileIndex(profiles, "duplicate-legacy", cfg); got != 0 {
t.Fatalf("duplicate legacy fallback index = %d, want 0", got)
}
})
t.Run("normalized exact identity", func(t *testing.T) {
profiles := []authpkg.Profile{{CorpID: "corp_profile_fixture", UserID: "identity_exact"}}
cfg := &authpkg.ProfilesConfig{Profiles: profiles}
if got := profileSwitchProfileIndex(profiles, "corp_profile_fixture : identity_exact", cfg); got != 0 {
t.Fatalf("normalized exact profile index = %d, want 0", got)
}
if got := profileSwitchProfileIndex(profiles, "corp_profile_fixture : missing", cfg); got != -1 {
t.Fatalf("missing normalized exact profile index = %d, want -1", got)
}
})
}
func TestCrossPlatformCoverageRuntimeRunnerPreservesBlankSelectorInSingleAndMultiRuns(t *testing.T) {
exact := authLogoutTestToken("corp_runner_blank")
exact.UserID = "identity_exact_runner"
other := authLogoutTestToken("corp_runner_other")
configDir := setupAuthLogoutProfiles(t, exact, other)
blank := authLogoutTestToken("corp_runner_blank")
blank.AccessToken = "access-unresolved-runner"
blank.RefreshToken = "refresh-unresolved-runner"
blank.UserID = ""
blank.UserName = ""
if err := authpkg.SaveTokenData(configDir, blank); err != nil {
t.Fatalf("SaveTokenData(blank) error = %v", err)
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
blankSelector := ""
for _, profile := range cfg.Profiles {
if profile.CorpID == blank.CorpID && profile.UserID == "" {
blankSelector = authpkg.ProfileSelectionSelector(profile, cfg)
break
}
}
if blankSelector == "" || blankSelector == blank.CorpID {
t.Fatalf("blank runner selector = %q, want exact local selector", blankSelector)
}
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
invocation := executor.Invocation{
Kind: "helper_invocation",
CanonicalProduct: "contact",
Tool: "get_current_user_profile",
}
authpkg.SetRuntimeProfile(blankSelector)
result, err := runner.Run(context.Background(), invocation)
if err != nil {
t.Fatalf("single blank Run() error = %v", err)
}
content := result.Response["content"].(map[string]any)
if got := content["runtimeProfile"]; got != blankSelector {
t.Fatalf("single blank runtime profile = %#v, want %q", got, blankSelector)
}
if got := authpkg.RuntimeProfile(); got != blankSelector {
t.Fatalf("single blank runtime restoration = %q, want %q", got, blankSelector)
}
authpkg.SetRuntimeProfile(blankSelector + ",corp_runner_other")
result, err = runner.Run(context.Background(), invocation)
if err != nil {
t.Fatalf("multi blank Run() error = %v", err)
}
entries := result.Response["content"].(map[string]any)["profiles"].([]any)
if len(entries) != 2 {
t.Fatalf("multi blank profiles = %#v, want two", entries)
}
first := entries[0].(map[string]any)
if first["selector"] != blankSelector || first["profile"] != blankSelector || first["userId"] != "" {
t.Fatalf("multi blank first entry = %#v", first)
}
}
func TestCrossPlatformCoveragePersonalBusSelectorCanonicalFallback(t *testing.T) {
authpkg.SetRuntimeProfile("")
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
identity := personal.Identity{
CorpID: "corp_event_fallback",
UserID: "identity_event_fallback",
SourceID: "open",
}
if got := personalBusProfileSelector(t.TempDir(), identity); got != "corp_event_fallback:identity_event_fallback" {
t.Fatalf("personal bus fallback selector = %q", got)
}
args := personalBusSpawnArgs(identity, "", "", " ")
if got := strings.Join(args, " "); !strings.Contains(got, "--profile corp_event_fallback:identity_event_fallback") {
t.Fatalf("personal bus default profile args = %q", got)
}
}
@@ -0,0 +1,107 @@
// 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 (
"strings"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
func TestPATFreshAuthorizationSaveUsesLoginIsolationBoundary(t *testing.T) {
configDir := t.TempDir()
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
const (
corpID = "corp_pat_login_boundary"
userID = "exact-user"
)
cfg := &authpkg.ProfilesConfig{
Version: 2,
Profiles: []authpkg.Profile{
{Name: "External Account", CorpID: corpID, CorpName: "PAT Boundary Organization"},
{Name: "Exact Account", CorpID: corpID, CorpName: "PAT Boundary Organization", UserID: userID},
},
}
blankSelector := authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg)
cfg.CurrentProfile = blankSelector
cfg.PrimaryProfile = blankSelector
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
blank := &authpkg.TokenData{AccessToken: "existing-unresolved", CorpID: corpID, CorpName: "PAT Boundary Organization"}
exact := &authpkg.TokenData{AccessToken: "existing-exact", CorpID: corpID, CorpName: "PAT Boundary Organization", UserID: userID}
if err := authpkg.SaveTokenDataKeychainForCorpID(corpID, blank); err != nil {
t.Fatalf("save unresolved token: %v", err)
}
if err := authpkg.SaveTokenDataKeychainForIdentity(corpID, userID, exact); err != nil {
t.Fatalf("save exact token: %v", err)
}
previousRuntimeProfile := authpkg.RuntimeProfile()
authpkg.SetRuntimeProfile("")
t.Cleanup(func() { authpkg.SetRuntimeProfile(previousRuntimeProfile) })
fresh := &authpkg.TokenData{AccessToken: "pat-fresh-unknown", CorpID: corpID, CorpName: "PAT Boundary Organization"}
err := patSaveTokenData(configDir, fresh)
if err == nil || !strings.Contains(err.Error(), "fresh UID-less token") {
t.Fatalf("patSaveTokenData() error = %v, want unresolved-sibling protection", err)
}
persisted, loadErr := authpkg.LoadTokenDataKeychainForCorpID(corpID)
if loadErr != nil || persisted.AccessToken != blank.AccessToken || persisted.UserID != "" {
t.Fatalf("PAT save changed unresolved sibling: token=%#v err=%v", persisted, loadErr)
}
}
func TestManualLoginSaveRepairsHalfMigratedGlobalBeforeOverwrite(t *testing.T) {
configDir := t.TempDir()
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
const (
corpID = "corp_manual_login_boundary"
userID = "legacy-user"
)
selector := corpID + ":" + userID
if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{
Version: 2,
CurrentProfile: selector,
Profiles: []authpkg.Profile{{
Name: "Legacy Exact Account", CorpID: corpID, CorpName: "Manual Boundary Organization", UserID: userID,
}},
}); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
legacy := &authpkg.TokenData{AccessToken: "only-legacy-copy", CorpID: corpID, CorpName: "Manual Boundary Organization"}
if err := authpkg.SaveTokenDataKeychain(legacy); err != nil {
t.Fatalf("save half-migrated global: %v", err)
}
manual := &authpkg.TokenData{AccessToken: "manual-default", ExpiresAt: time.Now().Add(time.Hour)}
if err := authSaveTokenData(configDir, manual); err != nil {
t.Fatalf("authSaveTokenData(manual) error = %v", err)
}
org, err := authpkg.LoadTokenDataKeychainForCorpID(corpID)
if err != nil || org.AccessToken != legacy.AccessToken || org.UserID != "" {
t.Fatalf("organization repair = %#v, %v", org, err)
}
identity, err := authpkg.LoadTokenDataKeychainForIdentity(corpID, userID)
if err != nil || identity.AccessToken != legacy.AccessToken || identity.UserID != userID {
t.Fatalf("identity repair = %#v, %v", identity, err)
}
global, err := authpkg.LoadTokenDataKeychain()
if err != nil || global.AccessToken != manual.AccessToken || global.CorpID != "" {
t.Fatalf("manual global = %#v, %v", global, err)
}
}
+9
View File
@@ -49,6 +49,15 @@ func RegisterPluginAuth(productID string, auth *PluginAuth) {
pluginAuthRegistry[productID] = auth
}
// ClearPluginAuth removes credentials for a plugin product. Registration uses
// this before applying an accepted descriptor so a descriptor without custom
// auth cannot inherit stale credentials from an earlier root construction.
func ClearPluginAuth(productID string) {
pluginAuthMu.Lock()
defer pluginAuthMu.Unlock()
delete(pluginAuthRegistry, productID)
}
// LookupPluginAuth returns the authentication credentials registered
// for the given product ID, or nil if none exists.
func LookupPluginAuth(productID string) (*PluginAuth, bool) {
@@ -0,0 +1,144 @@
// 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 (
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
)
func blankProfileSelectorAppFixture(blankName, corpName string) *authpkg.ProfilesConfig {
const (
corpID = "corp_selector_fixture"
exactUserID = "identity_exact_fixture"
)
exactSelector := corpID + ":" + exactUserID
cfg := &authpkg.ProfilesConfig{
Version: 2,
PrimaryProfile: exactSelector,
PreviousProfile: exactSelector,
OrgCurrentProfiles: map[string]string{
corpID: exactSelector,
},
Profiles: []authpkg.Profile{
{
Name: "Exact Fixture Account",
CorpID: corpID,
CorpName: corpName,
UserID: exactUserID,
UserName: "Exact Fixture Account",
Status: authpkg.ProfileStatusActive,
},
{
Name: blankName,
CorpID: corpID,
CorpName: corpName,
Status: authpkg.ProfileStatusActive,
},
},
}
cfg.CurrentProfile = authpkg.ProfileSelectionSelector(cfg.Profiles[1], cfg)
return cfg
}
func captureProfileListSelectors(t *testing.T, cfg *authpkg.ProfilesConfig) ([]string, []profileView) {
t.Helper()
originalLoadToken := profileLoadTokenData
selectors := make([]string, 0, len(cfg.Profiles))
profileLoadTokenData = func(_ string, selector string) (*authpkg.TokenData, error) {
selectors = append(selectors, selector)
return nil, authpkg.ErrTokenDataNotFound
}
t.Cleanup(func() { profileLoadTokenData = originalLoadToken })
views := profileViews("unused-config-dir", cfg)
return selectors, views
}
func TestCrossPlatformCoverageBlankProfileNameMatchingCorpNameRoundTripsThroughListAndTUI(t *testing.T) {
cfg := blankProfileSelectorAppFixture("Fixture Organization", "Fixture Organization")
blank := cfg.Profiles[1]
blankSelector := authpkg.ProfileSelectionSelector(blank, cfg)
if blankSelector == blank.Name || blankSelector == blank.CorpID {
t.Fatalf("unsafe blank selector = %q, want reserved exact selector", blankSelector)
}
if got := profileCLISelector(blank, cfg); got != blankSelector {
t.Errorf("profileCLISelector(blank) = %q, want %q", got, blankSelector)
}
if got := profileSwitchProfileIndex(cfg.Profiles, cfg.CurrentProfile, cfg); got != 1 {
t.Errorf("profileSwitchProfileIndex(blank current) = %d, want 1", got)
}
model := newProfileSwitchTUIModel(cfg, cfg.CurrentProfile)
if model.selected != 1 {
t.Errorf("TUI selected index = %d, want blank profile index 1", model.selected)
}
if got := model.selectedCorpID(); got != blankSelector {
t.Errorf("TUI selected selector = %q, want %q", got, blankSelector)
}
selectors, views := captureProfileListSelectors(t, cfg)
if len(selectors) != 2 || selectors[0] != cfg.PreviousProfile || selectors[1] != blankSelector {
t.Errorf("profile list token selectors = %#v, want exact then %q", selectors, blankSelector)
}
if len(views) != 2 {
t.Fatalf("profile list views = %#v, want two entries", views)
}
if views[0].IsCurrent {
t.Error("exact account should not be marked current when blank local selector is current")
}
if views[1].Profile != blankSelector || !views[1].IsCurrent {
t.Errorf("blank list view = %#v, want local selector marked current", views[1])
}
}
func TestCrossPlatformCoverageBlankProfileNameContainingColonWinsOverIdentityParsingInListAndTUI(t *testing.T) {
cfg := blankProfileSelectorAppFixture("legacy:outsourced", "Fixture Organization")
blank := cfg.Profiles[1]
blankSelector := authpkg.ProfileSelectionSelector(blank, cfg)
if blankSelector == blank.Name {
t.Fatalf("colon-containing name leaked as selector %q", blankSelector)
}
if _, _, parsedAsIdentity := authpkg.ParseIdentitySelector(blankSelector); parsedAsIdentity {
t.Fatalf("stable blank selector %q was parsed as an identity", blankSelector)
}
if got := profileCLISelector(blank, cfg); got != blankSelector {
t.Errorf("profileCLISelector(colon blank) = %q, want %q", got, blankSelector)
}
if got := profileSwitchProfileIndex(cfg.Profiles, cfg.CurrentProfile, cfg); got != 1 {
t.Errorf("profileSwitchProfileIndex(colon blank current) = %d, want 1", got)
}
model := newProfileSwitchTUIModel(cfg, cfg.CurrentProfile)
if model.selected != 1 {
t.Errorf("TUI selected index = %d, want colon-name blank profile index 1", model.selected)
}
if got := model.selectedCorpID(); got != blankSelector {
t.Errorf("TUI selected selector = %q, want %q", got, blankSelector)
}
selectors, views := captureProfileListSelectors(t, cfg)
if len(selectors) != 2 || selectors[0] != cfg.PreviousProfile || selectors[1] != blankSelector {
t.Errorf("profile list token selectors = %#v, want exact then %q", selectors, blankSelector)
}
if len(views) != 2 {
t.Fatalf("profile list views = %#v, want two entries", views)
}
if views[0].IsCurrent {
t.Error("exact account should not be marked current when colon-name blank selector is current")
}
if views[1].Profile != blankSelector || !views[1].IsCurrent {
t.Errorf("colon-name blank list view = %#v, want local selector marked current", views[1])
}
}
+2 -2
View File
@@ -1469,10 +1469,10 @@ func TestCrossPlatformCoveragePersonalEventPureCoverage(t *testing.T) {
if !ok {
t.Fatal("mention definition missing")
}
if err := renderPersonalSchema(io.Discard, def, ""); err != nil {
if err := renderPersonalSchema(io.Discard, def, "", false); err != nil {
t.Fatal(err)
}
if err := renderPersonalSchema(io.Discard, def, "yaml"); err == nil {
if err := renderPersonalSchema(io.Discard, def, "yaml", true); err == nil {
t.Fatal("unsupported schema format succeeded")
}
for _, key := range []string{"", "unknown", personal.EventMention, personal.EventFromUser} {
+137
View File
@@ -11,6 +11,7 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestToolCallerAdapterDryRunNeverInvokesRunner(t *testing.T) {
@@ -36,6 +37,66 @@ func TestToolCallerAdapterDryRunNeverInvokesRunner(t *testing.T) {
}
}
func TestToolCallerAdapterDryRunAllowsOnlyExplicitReadCapability(t *testing.T) {
runner := &readOnlyDryRunRunner{}
caller := newToolCallerAdapter(runner, &GlobalFlags{DryRun: true, Format: "json"})
result, err := caller.(edition.ReadToolCaller).CallReadTool(
context.Background(),
"im",
"search_groups",
map[string]any{"keyword": "project"},
)
if err != nil {
t.Fatalf("CallReadTool() error = %v", err)
}
if got := runner.readCalls.Load(); got != 1 {
t.Fatalf("read calls = %d, want 1", got)
}
if got := runner.regularCalls.Load(); got != 0 {
t.Fatalf("regular calls = %d, want 0", got)
}
if runner.invocation.DryRun {
t.Fatal("read-only invocation was left in dry-run mode")
}
if result == nil || len(result.Content) != 1 || !strings.Contains(result.Content[0].Text, `"read":true`) {
t.Fatalf("read result = %#v", result)
}
failClosed := newToolCallerAdapter(&countingErrorRunner{}, &GlobalFlags{DryRun: true})
if _, err := failClosed.(edition.ReadToolCaller).CallReadTool(
context.Background(), "im", "search_groups", nil,
); err == nil {
t.Fatal("runner without read-only capability was accepted")
}
}
func TestCrossPlatformCoverageReadOnlyGuardErrorPaths(t *testing.T) {
var nilAdapter *toolCallerAdapter
if _, err := nilAdapter.CallReadTool(context.Background(), "im", "search_groups", nil); err == nil {
t.Fatal("nil adapter accepted a read-only call")
}
regularRunner := &capturingSuccessRunner{}
regular := newToolCallerAdapter(regularRunner, &GlobalFlags{DryRun: false, Format: "json"})
if _, err := regular.(edition.ReadToolCaller).CallReadTool(context.Background(), "im", "search_groups", nil); err != nil {
t.Fatalf("non-dry read should use the regular runner: %v", err)
}
if got := regularRunner.calls.Load(); got != 1 {
t.Fatalf("regular runner calls = %d, want 1", got)
}
readFailure := newToolCallerAdapter(&failingReadOnlyRunner{}, &GlobalFlags{DryRun: true, Format: "json"})
if _, err := readFailure.(edition.ReadToolCaller).CallReadTool(context.Background(), "im", "search_groups", nil); err == nil {
t.Fatal("read-only runner error was swallowed")
}
var nilRuntime *runtimeRunner
if _, err := nilRuntime.RunReadOnly(context.Background(), executor.Invocation{}); err == nil {
t.Fatal("nil runtime runner accepted a read-only call")
}
}
func TestRuntimeRunnerGlobalDryRunStopsBeforeInjectedFallback(t *testing.T) {
fallback := &countingErrorRunner{}
runner := &runtimeRunner{globalFlags: &GlobalFlags{DryRun: true}, fallback: fallback}
@@ -56,6 +117,38 @@ func TestRuntimeRunnerGlobalDryRunStopsBeforeInjectedFallback(t *testing.T) {
}
}
func TestRuntimeRunnerReadOnlyClonePreservesGlobalDryRunBarrier(t *testing.T) {
fallback := &capturingSuccessRunner{}
flags := &GlobalFlags{DryRun: true}
runner := &runtimeRunner{globalFlags: flags, fallback: fallback}
invocation := executor.NewHelperInvocation(
"test",
"im",
"search_groups",
map[string]any{"keyword": "project"},
)
if _, err := runner.RunReadOnly(context.Background(), invocation); err != nil {
t.Fatalf("RunReadOnly() error = %v", err)
}
if got := fallback.calls.Load(); got != 1 {
t.Fatalf("fallback calls = %d, want 1", got)
}
if fallback.invocation.DryRun {
t.Fatal("read-only fallback invocation was left in dry-run mode")
}
if !flags.DryRun {
t.Fatal("RunReadOnly mutated the process-wide dry-run flag")
}
if _, err := runner.Run(context.Background(), invocation); err != nil {
t.Fatalf("ordinary Run() error = %v", err)
}
if got := fallback.calls.Load(); got != 1 {
t.Fatalf("ordinary dry-run reached fallback; calls = %d", got)
}
}
type countingErrorRunner struct {
calls atomic.Int64
}
@@ -64,3 +157,47 @@ func (r *countingErrorRunner) Run(context.Context, executor.Invocation) (executo
r.calls.Add(1)
return executor.Result{}, errors.New("runner must not be called")
}
type readOnlyDryRunRunner struct {
regularCalls atomic.Int64
readCalls atomic.Int64
invocation executor.Invocation
}
func (r *readOnlyDryRunRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
r.regularCalls.Add(1)
return executor.Result{}, errors.New("regular runner must not be called")
}
func (r *readOnlyDryRunRunner) RunReadOnly(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.readCalls.Add(1)
r.invocation = invocation
return executor.Result{
Invocation: invocation,
Response: map[string]any{"read": true},
}, nil
}
type capturingSuccessRunner struct {
calls atomic.Int64
invocation executor.Invocation
}
func (r *capturingSuccessRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.calls.Add(1)
r.invocation = invocation
return executor.Result{
Invocation: invocation,
Response: map[string]any{"read": true},
}, nil
}
type failingReadOnlyRunner struct{}
func (*failingReadOnlyRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
return executor.Result{}, errors.New("regular runner must not be called")
}
func (*failingReadOnlyRunner) RunReadOnly(context.Context, executor.Invocation) (executor.Result, error) {
return executor.Result{}, errors.New("read failed")
}
+46 -9
View File
@@ -112,12 +112,13 @@ func newEventConsumeCommand() *cobra.Command {
dryRun bool
foreground bool
asIdentity string
flatten bool
personalOpts personalConsumeOptions
streamOpts eventStreamTicketOptions
)
cmd := &cobra.Command{
Use: "consume [event_key]",
Use: "consume [event_key...]",
Short: "订阅事件流并输出到 stdout",
Long: `订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。
@@ -127,15 +128,24 @@ func newEventConsumeCommand() *cobra.Command {
json 每事件多行美化 JSON(必须配 --max-events 或 --duration)
pretty 同 json,未来加颜色
raw 仅 SDK 原始 payload,无外层封装
compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)
compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor
数据结构:
ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers)
--flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费
默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加
--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用
SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览、确认后加
--yes,绝不要 kill -9。
可提供多个 event_key。多事件会各自创建订阅和本地 consumer,但共享同一 bus、
远程连接、输出和 duration/max-events。用户类事件必须共享一个 --user 或
--open-dingtalk-id,群类事件必须共享一个 --group;用户类与群类不能混用。
全部 consumer 就绪后 stderr 输出 [event] ready event_count=<n> bus_pid=<pid>。
--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费
通常不需要设置。`,
Args: cobra.MaximumNArgs(1),
Args: cobra.ArbitraryArgs,
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, args []string) error {
as, err := eventNormalizeAs(asIdentity)
@@ -143,7 +153,17 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
return err
}
if as == "user" {
personalOpts.EventKey = firstArg(args)
personalOpts.EventKeys = dedupePersonalEventKeys(args)
personalOpts.EventKey = firstArg(personalOpts.EventKeys)
personalOpts.Flatten = flatten
if len(personalOpts.EventKeys) > 1 {
if err := rejectPersonalMultiEventFlags(c,
"subscribe-id", "rule", "event-types", "filter",
"foreground", "force", "debug-raw-events",
); err != nil {
return fmt.Errorf("event consume: %w", err)
}
}
personalOpts.Common = commonConsumeOptions{
EventTypes: eventTypes,
Filter: filter,
@@ -167,6 +187,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
return fmt.Errorf("event consume: --debug-raw-events is only supported with --as user")
}
if err := rejectChangedFlags(c, "user",
"flatten",
"subscribe-id",
"rule",
"name",
@@ -280,6 +301,8 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
"提示 bus 客户端期望 compact 渲染(语义透传,bus 仍按原 payload 投递)")
f.StringVarP(&formatRaw, "format", "f", "ndjson",
"输出格式 (ndjson/json/pretty/raw/compact);事件流默认 ndjson")
f.BoolVar(&flatten, "flatten", false,
"将个人事件 transport envelope 投影为稳定的顶层业务字段")
f.StringVar(&outputDir, "output-dir", "",
"每事件写一个文件到该目录 ({type}_{id}_{ts}.json);与 stdout 互斥")
f.StringArrayVar(&routesRaw, "route", nil,
@@ -320,7 +343,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
f.StringVar(&personalOpts.GroupID, "group", "",
"group 规则:openConversationId")
f.StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "",
"个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws")
"个人事件控制面 base URL;默认由 MCP base 派生 /dws")
f.BoolVar(&personalOpts.DebugRawEvents, "debug-raw-events", false,
"个人事件联调:绕过本地 event type/subscribe_id 过滤,输出当前 personal stream bus 收到的所有事件")
f.StringVar(&streamOpts.Mode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")),
@@ -328,13 +351,14 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
f.StringVar(&streamOpts.SourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
"个人 Stream sourceId;开源版默认 open,可由 edition 覆盖")
f.StringVar(&streamOpts.TicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")),
"个人 Stream 取票 URL;默认由 MCP base URL 派生")
"个人 Stream 取票 URL;默认由 MCP base 派生 /stream/connections/ticket")
hideEventInternalFlags(cmd, "as")
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
Name: "event_key",
Type: "string",
Description: "要消费的个人事件码;省略时仅适用于显式配置其它事件来源的兼容模式",
Description: "要消费的一个或多个个人事件码;多个事件必须共享同一目标和过滤上下文",
Required: false,
Variadic: true,
Index: 0,
})
return cmd
@@ -768,7 +792,7 @@ func newEventStatusCommand() *cobra.Command {
cmd.Flags().StringVar(&personalOpts.EventKey, "event", "", "个人事件 event_key 过滤")
cmd.Flags().StringVar(&personalOpts.Status, "status", "active", "个人订阅状态过滤: active|paused|error|deleted|all")
cmd.Flags().StringVar(&personalOpts.SubscribeID, "subscribe-id", "", "个人订阅 ID 过滤")
cmd.Flags().StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws")
cmd.Flags().StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws")
cmd.Flags().StringVar(&personalOpts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
"个人事件 sourceId;开源版默认 open,可由 edition 覆盖")
hideEventInternalFlags(cmd, "as", "all", "all-editions", "client-id", "fail-on-orphan")
@@ -1066,7 +1090,7 @@ func newEventStopCommand() *cobra.Command {
},
}
cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user")
cmd.Flags().StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws")
cmd.Flags().StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws")
cmd.Flags().StringVar(&opts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
"个人事件 sourceId;开源版默认 open,可由 edition 覆盖")
cmd.Flags().BoolVar(&opts.All, "all", false, "取消当前身份下本地记录的所有个人订阅")
@@ -1183,6 +1207,19 @@ func rejectPersonalEventUnsupportedFlags(c *cobra.Command, names ...string) erro
return fmt.Errorf("%s are not supported for personal events", strings.Join(changed, ", "))
}
func rejectPersonalMultiEventFlags(c *cobra.Command, names ...string) error {
changed := make([]string, 0, len(names))
for _, name := range names {
if f := c.Flags().Lookup(name); f != nil && f.Changed {
changed = append(changed, "--"+name)
}
}
if len(changed) == 0 {
return nil
}
return fmt.Errorf("%s are not supported when consuming multiple events", strings.Join(changed, ", "))
}
func rejectChangedFlags(c *cobra.Command, supportedAs string, names ...string) error {
changed := make([]string, 0, len(names))
for _, name := range names {
+399 -29
View File
@@ -61,6 +61,8 @@ type commonConsumeOptions struct {
type personalConsumeOptions struct {
Common commonConsumeOptions
EventKey string
EventKeys []string
Flatten bool
DebugRawEvents bool
SubscribeID string
Rule string
@@ -111,6 +113,7 @@ type personalStreamSourceOptions struct {
var (
personalResolveEventIdentity = resolvePersonalEventIdentity
personalLookupDefinition = personal.Lookup
personalEnsureSubscription = ensurePersonalSubscription
personalGetSubscription = (*personal.Client).GetSubscription
personalCreateSubscription = (*personal.Client).CreateSubscription
@@ -120,6 +123,7 @@ var (
personalRemoveRunStates = personal.RemoveRunStates
personalLoadRunStates = personal.LoadRunStates
personalConsumeRun = consume.Run
personalConsumeRunMany = consume.RunMany
personalValidateConsumeConfig = consume.ValidateConfig
personalValidateNoOutputConflict = consume.ValidateNoOutputConflict
personalNewStreamSource = newPersonalStreamSource
@@ -128,6 +132,7 @@ var (
personalQueryEntry = busctl.QueryEntry
personalQueryStatus = busctl.QueryStatus
personalStopBus = busctl.Stop
personalStopConsumers = busctl.StopConsumers
personalFindProcess = os.FindProcess
personalSignalProcess = (*os.Process).Signal
personalResolveAuxiliaryAccessToken = ResolveAuxiliaryAccessToken
@@ -140,6 +145,7 @@ var (
func newEventSchemaCommand() *cobra.Command {
var asIdentity string
var formatRaw string
var flatten bool
cmd := &cobra.Command{
Use: "schema <event_key>",
Short: "显示事件 schema",
@@ -157,11 +163,12 @@ func newEventSchemaCommand() *cobra.Command {
if !def.Public {
return personal.PublicAvailabilityError(args[0])
}
return renderPersonalSchema(c.OutOrStdout(), def, formatRaw)
return renderPersonalSchema(c.OutOrStdout(), def, formatRaw, flatten)
},
}
cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user")
cmd.Flags().StringVarP(&formatRaw, "format", "f", "json", "输出格式: json")
cmd.Flags().BoolVar(&flatten, "flatten", false, "显示 --flatten 消费模式对应的顶层业务字段 schema")
hideEventInternalFlags(cmd, "as")
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
Name: "event_key",
@@ -189,7 +196,7 @@ func runPersonalEventList(c *cobra.Command, opts personalListOptions) error {
return tw.Flush()
}
func renderPersonalSchema(w io.Writer, def personal.Definition, format string) error {
func renderPersonalSchema(w io.Writer, def personal.Definition, format string, flatten bool) error {
format = strings.ToLower(strings.TrimSpace(format))
if format == "" {
format = "json"
@@ -199,28 +206,29 @@ func renderPersonalSchema(w io.Writer, def personal.Definition, format string) e
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(personal.BuildSchemaDocument(def))
return enc.Encode(personal.BuildSchemaDocumentForMode(def, flatten))
}
func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) error {
keys := dedupePersonalEventKeys(opts.EventKeys)
if len(keys) == 0 && strings.TrimSpace(opts.EventKey) != "" {
keys = []string{strings.TrimSpace(opts.EventKey)}
}
if len(keys) <= 1 {
if len(keys) == 1 {
opts.EventKey = keys[0]
}
return runPersonalEventConsumeSingle(c, opts)
}
opts.EventKeys = keys
return runPersonalEventConsumeMany(c, opts)
}
func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions) error {
ctx := c.Context()
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return err
}
configDir := defaultConfigDir()
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
identityHash := dwsevent.IdentityHash(identity.Key())
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
rawFormat = opts.Common.FormatRaw
@@ -229,8 +237,26 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
if fellback && !opts.Common.Quiet {
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
}
projector := personalEventProjector(opts.DebugRawEvents)
if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
projector := personalEventProjector(opts.DebugRawEvents, opts.Flatten)
configDir := defaultConfigDir()
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
identityHash := dwsevent.IdentityHash(identity.Key())
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
spawnProfileSelector := personalBusProfileSelector(configDir, identity)
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
if opts.Common.DryRun {
if strings.TrimSpace(opts.SubscribeID) == "" {
if err := validatePersonalSubscriptionOptions(opts); err != nil {
@@ -241,12 +267,13 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir)),
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
EventKey: opts.EventKey,
Format: normalised,
Flatten: opts.Flatten,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Projector: projector,
@@ -297,12 +324,13 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL),
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
EventKey: eventKey,
Format: normalised,
Flatten: opts.Flatten,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Projector: projector,
@@ -366,11 +394,287 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
return err
}
func personalEventProjector(debugRawEvents bool) consume.Projector {
type personalMultiSubscription struct {
Sub *personal.Subscription
EventKey string
RuleType string
}
func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions) error {
plans, err := preparePersonalMultiOptions(opts)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
rawFormat = opts.Common.FormatRaw
}
normalised, fellback := consume.NormalizeFormat(rawFormat)
if fellback && !opts.Common.Quiet {
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
}
if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
projector := personalEventProjector(false, opts.Flatten)
ctx := c.Context()
configDir := defaultConfigDir()
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
identityHash := dwsevent.IdentityHash(identity.Key())
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
baseCfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir)),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
Format: normalised,
Flatten: opts.Flatten,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Projector: projector,
Stdout: c.OutOrStdout(),
Stderr: c.ErrOrStderr(),
Quiet: opts.Common.Quiet,
}
applyEventConsumeStdin(&baseCfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
if err := personalValidateConsumeConfig(baseCfg); err != nil {
return err
}
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
if err := personalValidateNoOutputConflict(baseCfg, o.Value.String()); err != nil {
return err
}
}
if opts.Common.DryRun {
printPersonalMultiDryRun(c.ErrOrStderr(), baseCfg, plans)
return nil
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
created := make([]personalMultiSubscription, 0, len(plans))
cleanup := func() {
ids := make([]string, 0, len(created))
for i := len(created) - 1; i >= 0; i-- {
id := strings.TrimSpace(created[i].Sub.SubscribeID)
ids = append(ids, id)
if err := personalDeleteSubscription(client, context.Background(), id); err != nil {
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to clean personal subscription %s: %v\n", id, err)
}
}
if len(ids) > 0 {
if err := personalRemoveRunStates(workDir, ids); err != nil {
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to clean personal event run state: %v\n", err)
}
}
}
seenSubscribeIDs := make(map[string]struct{}, len(plans))
for _, plan := range plans {
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, plan)
if err != nil {
cleanup()
return fmt.Errorf("event consume --as user: create subscription for %s: %w", plan.EventKey, err)
}
if sub == nil {
cleanup()
return fmt.Errorf("event consume --as user: server returned an empty subscription for %s", plan.EventKey)
}
id := strings.TrimSpace(sub.SubscribeID)
if id == "" {
cleanup()
return fmt.Errorf("event consume --as user: server returned empty subscribe_id for %s", plan.EventKey)
}
if _, exists := seenSubscribeIDs[id]; exists {
_ = personalDeleteSubscription(client, context.Background(), id)
cleanup()
return fmt.Errorf("event consume --as user: server returned duplicate subscribe_id %s", id)
}
seenSubscribeIDs[id] = struct{}{}
item := personalMultiSubscription{Sub: sub, EventKey: eventKey, RuleType: ruleType}
created = append(created, item)
if err := personalUpsertRunState(workDir, personal.RunState{
SubscribeID: id,
EventKey: eventKey,
RuleType: ruleType,
ClientID: identity.ClientID,
SourceID: identity.SourceID,
IdentityHash: identityHash,
}); err != nil {
cleanup()
return fmt.Errorf("event consume --as user: save run state for %s: %w", eventKey, err)
}
}
defer cleanup()
specs := make([]consume.ConsumerSpec, 0, len(created))
for _, item := range created {
specs = append(specs, consume.ConsumerSpec{
EventKey: item.EventKey,
EventTypes: []string{item.EventKey},
SubscribeID: item.Sub.SubscribeID,
ReadySubscribeID: item.Sub.SubscribeID,
})
}
if err := personalConsumeRunMany(ctx, baseCfg, specs); err != nil {
return err
}
return nil
}
func preparePersonalMultiOptions(opts personalConsumeOptions) ([]personalConsumeOptions, error) {
if strings.TrimSpace(opts.SubscribeID) != "" {
return nil, errors.New("--subscribe-id is not supported when consuming multiple events")
}
if strings.TrimSpace(opts.Rule) != "" {
return nil, errors.New("--rule is not supported when consuming multiple events")
}
if len(opts.Common.EventTypes) > 0 {
return nil, errors.New("--event-types is not supported when consuming multiple events; use event_key positionals")
}
if strings.TrimSpace(opts.Common.Filter) != "" {
return nil, errors.New("--filter is not supported when consuming multiple events; use event_key positionals")
}
if opts.Common.Foreground || opts.Common.Force {
return nil, errors.New("--foreground/--force are not supported when consuming multiple events")
}
if opts.DebugRawEvents {
return nil, errors.New("--debug-raw-events is not supported when consuming multiple events")
}
keys := dedupePersonalEventKeys(opts.EventKeys)
if len(keys) < 2 {
return nil, errors.New("multiple event keys are required")
}
hasUserScope := false
hasGroupScope := false
for _, eventKey := range keys {
def, ok := personalLookupDefinition(eventKey)
if !ok {
return nil, fmt.Errorf("unknown personal event key %q", eventKey)
}
if !def.Public {
return nil, personal.PublicAvailabilityError(eventKey)
}
switch def.RuleType {
case "singleChat", "sender":
hasUserScope = true
case "group":
hasGroupScope = true
}
if (strings.TrimSpace(opts.QueryCSV) != "" || strings.TrimSpace(opts.FilterJSON) != "") && !personal.SupportsMessageFilter(eventKey) {
return nil, fmt.Errorf("--query/--filter-json require all selected events to be message receive events; %s is not", eventKey)
}
}
if hasUserScope && hasGroupScope {
return nil, errors.New("user-scoped and group-scoped events cannot be consumed in one command")
}
userID := strings.TrimSpace(opts.UserID)
openID := strings.TrimSpace(opts.OpenDingTalkID)
groupID := strings.TrimSpace(opts.GroupID)
if userID != "" && openID != "" {
return nil, errors.New("--user and --open-dingtalk-id are mutually exclusive")
}
switch {
case hasUserScope:
if groupID != "" {
return nil, errors.New("--group cannot be used with user-scoped events")
}
if userID == "" && openID == "" {
return nil, errors.New("one of --user or --open-dingtalk-id is required for the selected events")
}
case hasGroupScope:
if userID != "" || openID != "" {
return nil, errors.New("--user/--open-dingtalk-id cannot be used with group-scoped events")
}
if groupID == "" {
return nil, errors.New("--group is required for the selected events")
}
default:
if userID != "" || openID != "" || groupID != "" {
return nil, errors.New("the selected events do not use --user, --open-dingtalk-id, or --group")
}
}
plans := make([]personalConsumeOptions, 0, len(keys))
for _, eventKey := range keys {
def, _ := personalLookupDefinition(eventKey)
plan := opts
plan.EventKey = eventKey
plan.EventKeys = nil
switch def.RuleType {
case "at", "all":
plan.UserID = ""
plan.OpenDingTalkID = ""
plan.GroupID = ""
case "singleChat", "sender":
plan.GroupID = ""
case "group":
plan.UserID = ""
plan.OpenDingTalkID = ""
}
if err := validatePersonalSubscriptionOptions(plan); err != nil {
return nil, err
}
plans = append(plans, plan)
}
return plans, nil
}
func printPersonalMultiDryRun(w io.Writer, cfg consume.Config, plans []personalConsumeOptions) {
preview := cfg
preview.EventTypes = make([]string, 0, len(plans))
for _, plan := range plans {
preview.EventTypes = append(preview.EventTypes, plan.EventKey)
}
consume.PrintDryRun(w, preview)
for i, plan := range plans {
ruleType, ruleParam, _ := personal.BuildRuleParam(plan.EventKey, personal.RuleOptions{
UserID: plan.UserID, OpenDingTalkID: plan.OpenDingTalkID, GroupID: plan.GroupID,
})
_, filter, _ := personal.BuildFilter(plan.FilterJSON, plan.QueryCSV)
ruleJSON, _ := personal.CanonicalJSON(ruleParam)
fmt.Fprintf(w, " subscription[%d] : event_key=%s rule_type=%s rule_param=%s",
i, plan.EventKey, ruleType, ruleJSON)
if filter != "" {
fmt.Fprintf(w, " filter=%s", filter)
}
fmt.Fprintln(w)
}
}
func personalEventProjector(debugRawEvents, flatten bool) consume.Projector {
if debugRawEvents {
return func(ev transport.Event) (any, error) { return ev, nil }
}
return personal.ProjectOutput
if flatten {
return personal.ProjectOutput
}
return nil
}
func validatePersonalEventOutputMode(flatten, debugRawEvents bool, format consume.Format) error {
if !flatten {
return nil
}
if debugRawEvents {
return fmt.Errorf("--flatten and --debug-raw-events are mutually exclusive")
}
if format == consume.FormatRaw {
return fmt.Errorf("--flatten and --format raw are mutually exclusive")
}
return nil
}
func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOptions, subscribeID, eventKey string) {
@@ -623,7 +927,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
if err := personalRemoveRunStates(workDir, subscribeIDs); err != nil {
return fmt.Errorf("event stop --as user: update local state: %w", err)
}
if err := interruptPersonalConsumers(ipcEndpoint, subscribeIDs); err != nil {
if err := stopPersonalConsumers(c.ErrOrStderr(), ipcEndpoint, subscribeIDs); err != nil {
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to stop matching local consume process: %v\n", err)
}
@@ -711,6 +1015,17 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
return nil
}
func stopPersonalConsumers(w io.Writer, ipcEndpoint string, subscribeIDs []string) error {
if _, err := personalStopConsumers(ipcEndpoint, subscribeIDs); err == nil {
return nil
} else if !errors.Is(err, busctl.ErrConsumerStopUnsupported) {
return err
} else {
fmt.Fprintf(w, "WARN: running bus does not support targeted consumer stop; falling back to process signal: %v\n", err)
}
return interruptPersonalConsumers(ipcEndpoint, subscribeIDs)
}
func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, busState string) {
if single && len(subscribeIDs) == 1 {
fmt.Fprintf(w, "cancelled personal subscription %s; %s\n", subscribeIDs[0], busState)
@@ -845,7 +1160,44 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio
})
}
func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL string) []string {
func personalBusProfileSelector(configDir string, identity personal.Identity) string {
// The parent already resolved and loaded this selector. Preserve it before
// consulting identity metadata: personal event discovery can fill an empty
// token userId from runtime defaults, and that inferred value must not turn a
// historical unresolved account into a different exact same-corp account in
// the detached child.
if selector := strings.TrimSpace(authpkg.RuntimeProfile()); selector != "" {
return selector
}
if cfg, err := authpkg.LoadProfiles(configDir); err == nil && cfg != nil {
// With no explicit process-local override, LoadTokenData selected the
// persisted current profile. Prefer that selection over the enriched
// identity: $currentUserId may describe an exact same-corp account even
// though the token came from the historical unresolved profile.
currentSelector := strings.TrimSpace(cfg.CurrentProfile)
for i := range cfg.Profiles {
profile := cfg.Profiles[i]
selector := authpkg.ProfileSelectionSelector(profile, cfg)
if selector == currentSelector &&
(strings.TrimSpace(identity.CorpID) == "" || strings.TrimSpace(profile.CorpID) == strings.TrimSpace(identity.CorpID)) {
return selector
}
}
for i := range cfg.Profiles {
profile := cfg.Profiles[i]
if strings.TrimSpace(profile.CorpID) == strings.TrimSpace(identity.CorpID) &&
strings.TrimSpace(profile.UserID) == strings.TrimSpace(identity.UserID) {
return authpkg.ProfileSelectionSelector(profile, cfg)
}
}
}
return authpkg.ProfileSelector(authpkg.Profile{
CorpID: identity.CorpID,
UserID: identity.UserID,
})
}
func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL string, profileSelectors ...string) []string {
args := []string{
"--source-kind", string(dwsevent.SourceKindPersonalStream),
"--stream-source-id", identity.SourceID,
@@ -854,10 +1206,11 @@ func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL stri
// credentials as the parent, including when one organization has multiple
// logged-in users.
if cid := strings.TrimSpace(identity.CorpID); cid != "" {
args = append(args, "--profile", authpkg.ProfileSelector(authpkg.Profile{
CorpID: identity.CorpID,
UserID: identity.UserID,
}))
profileSelector := authpkg.ProfileSelector(authpkg.Profile{CorpID: identity.CorpID, UserID: identity.UserID})
if len(profileSelectors) > 0 && strings.TrimSpace(profileSelectors[0]) != "" {
profileSelector = strings.TrimSpace(profileSelectors[0])
}
args = append(args, "--profile", profileSelector)
}
if strings.TrimSpace(ticketMode) != "" {
args = append(args, "--stream-ticket-mode", ticketMode)
@@ -905,6 +1258,23 @@ func firstNonEmptyPersonalString(values ...string) string {
return ""
}
func dedupePersonalEventKeys(values []string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func personalEventControlBaseURL(raw, configDir string) string {
if v := strings.TrimSpace(raw); v != "" {
return strings.TrimRight(v, "/")
@@ -930,7 +1300,7 @@ func personalEventMCPBaseURL(configDir string) string {
if v := configuredMCPBaseURL(configDir); v != "" {
return strings.TrimRight(v, "/")
}
return config.DefaultMCPBaseURL
return strings.TrimRight(config.DefaultMCPBaseURL, "/")
}
func configuredMCPBaseURL(configDir string) string {
+71 -4
View File
@@ -20,6 +20,7 @@ import (
"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/transport"
"github.com/spf13/cobra"
)
func TestApplyPersonalConsumeFiltersDebugRawEvents(t *testing.T) {
@@ -52,11 +53,14 @@ func TestApplyPersonalConsumeFiltersDefault(t *testing.T) {
}
}
func TestPersonalEventProjectorUsesRawEnvelopeForDebug(t *testing.T) {
if personalEventProjector(false) == nil {
t.Fatal("normal personal consume projector = nil")
func TestPersonalEventProjectorSelectsExplicitModes(t *testing.T) {
if personalEventProjector(false, false) != nil {
t.Fatal("default personal consume should preserve transport envelope")
}
projector := personalEventProjector(true)
if personalEventProjector(false, true) == nil {
t.Fatal("flatten personal consume projector = nil")
}
projector := personalEventProjector(true, false)
if projector == nil {
t.Fatal("debug raw personal consume projector = nil")
}
@@ -74,6 +78,69 @@ func TestPersonalEventProjectorUsesRawEnvelopeForDebug(t *testing.T) {
}
}
func TestEventConsumeFlattenRejectsRawModesBeforeIdentityResolution(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want string
}{
{
name: "raw format",
args: []string{personal.EventMention, "--flatten", "--format", "raw"},
want: "--flatten and --format raw are mutually exclusive",
},
{
name: "raw debug",
args: []string{personal.EventMention, "--flatten", "--debug-raw-events"},
want: "--flatten and --debug-raw-events are mutually exclusive",
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
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)
}
if strings.Contains(err.Error(), "login") || strings.Contains(err.Error(), "token") {
t.Fatalf("output-mode validation ran after identity resolution: %v", err)
}
})
}
}
func TestValidatePersonalEventOutputModeAllowsFlattenStructuredFormats(t *testing.T) {
for _, format := range []consume.Format{consume.FormatNDJSON, consume.FormatJSON, consume.FormatPretty, consume.FormatCompact} {
if err := validatePersonalEventOutputMode(true, false, format); err != nil {
t.Fatalf("validatePersonalEventOutputMode(true, false, %q) error = %v", format, err)
}
}
}
func TestEventConsumeFlattenFlagIsForwarded(t *testing.T) {
oldRun := eventRunPersonalConsume
t.Cleanup(func() { eventRunPersonalConsume = oldRun })
var got personalConsumeOptions
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
got = opts
return nil
}
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{personal.EventMention, "--flatten", "--format", "compact"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if !got.Flatten || got.Common.FormatRaw != "compact" {
t.Fatalf("forwarded options = %#v", got)
}
}
func TestEventConsumeDebugRawEventsRequiresUserMode(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
+661
View File
@@ -0,0 +1,661 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"bytes"
"context"
"errors"
"io"
"os"
"reflect"
"strings"
"testing"
"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/transport"
"github.com/spf13/cobra"
)
func TestEventConsumeAcceptsOrderedVariadicEventKeys(t *testing.T) {
oldRun := eventRunPersonalConsume
defer func() { eventRunPersonalConsume = oldRun }()
var got personalConsumeOptions
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
got = opts
return nil
}
cmd := newEventConsumeCommand()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{
personal.EventMention,
personal.EventSingleChat,
personal.EventMention,
"--user", "test-user-001",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
want := []string{personal.EventMention, personal.EventSingleChat}
if !reflect.DeepEqual(got.EventKeys, want) || got.EventKey != personal.EventMention {
t.Fatalf("event keys = %#v, first = %q", got.EventKeys, got.EventKey)
}
}
func TestPreparePersonalMultiOptionsCombinationMatrix(t *testing.T) {
tests := []struct {
name string
opts personalConsumeOptions
wantErr string
}{
{
name: "no target events",
opts: personalConsumeOptions{EventKeys: []string{personal.EventMention, personal.EventAllSingleChat}},
},
{
name: "user and no target",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O, personal.EventMention},
UserID: "test-user-001",
},
},
{
name: "group and no target",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventInChat, personal.EventGroupUpdated, personal.EventMention},
GroupID: "cid-test",
},
},
{
name: "open dingtalk id",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventSingleChat, personal.EventRecallO2O},
OpenDingTalkID: "open-test-user",
},
},
{
name: "user and group mixed",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventSingleChat, personal.EventInChat},
UserID: "test-user-001",
},
wantErr: "cannot be consumed in one command",
},
{
name: "duplicate keys collapse to one",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventMention},
},
wantErr: "multiple event keys are required",
},
{
name: "unknown event",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventMention, "user_im_unknown"},
},
wantErr: "unknown personal event key",
},
{
name: "missing user target",
opts: personalConsumeOptions{EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O}},
wantErr: "one of --user or --open-dingtalk-id",
},
{
name: "missing group target",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventInChat, personal.EventGroupUpdated},
},
wantErr: "--group is required",
},
{
name: "user identity flags conflict",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O},
UserID: "test-user-001",
OpenDingTalkID: "open-test-user",
},
wantErr: "mutually exclusive",
},
{
name: "group target on user events",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O},
UserID: "test-user-001",
GroupID: "cid-test",
},
wantErr: "--group cannot be used",
},
{
name: "user target on group events",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventInChat, personal.EventGroupUpdated},
UserID: "test-user-001",
GroupID: "cid-test",
},
wantErr: "cannot be used with group-scoped events",
},
{
name: "target on no target events",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
UserID: "test-user-001",
},
wantErr: "do not use --user",
},
{
name: "filter message events",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllGroupChat},
QueryCSV: "alarm",
},
},
{
name: "filter mixed with action",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O},
UserID: "test-user-001",
QueryCSV: "alarm",
},
wantErr: "require all selected events to be message receive events",
},
{
name: "invalid message filter",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
FilterJSON: "{",
},
wantErr: "filter",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
plans, err := preparePersonalMultiOptions(test.opts)
if test.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want %q", err, test.wantErr)
}
return
}
if err != nil {
t.Fatalf("preparePersonalMultiOptions() error = %v", err)
}
if len(plans) != len(test.opts.EventKeys) {
t.Fatalf("plans = %d, want %d", len(plans), len(test.opts.EventKeys))
}
for _, plan := range plans {
def, _ := personal.Lookup(plan.EventKey)
if def.RuleType == "at" || def.RuleType == "all" {
if plan.UserID != "" || plan.OpenDingTalkID != "" || plan.GroupID != "" {
t.Fatalf("no-target plan retained target: %#v", plan)
}
}
}
})
}
}
func TestPreparePersonalMultiOptionsRejectsNonPublicEvent(t *testing.T) {
oldLookup := personalLookupDefinition
t.Cleanup(func() { personalLookupDefinition = oldLookup })
personalLookupDefinition = func(eventKey string) (personal.Definition, bool) {
def, ok := personal.Lookup(eventKey)
if eventKey == personal.EventAllSingleChat {
def.Public = false
}
return def, ok
}
_, err := preparePersonalMultiOptions(personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
})
if err == nil || !strings.Contains(err.Error(), "not publicly available") {
t.Fatalf("error = %v", err)
}
}
func TestDedupePersonalEventKeysSkipsEmptyValues(t *testing.T) {
got := dedupePersonalEventKeys([]string{"", " event-a ", "event-a", "event-b"})
if !reflect.DeepEqual(got, []string{"event-a", "event-b"}) {
t.Fatalf("deduped keys = %#v", got)
}
}
func TestPreparePersonalMultiOptionsRejectsSingleOnlyFlags(t *testing.T) {
base := personalConsumeOptions{EventKeys: []string{personal.EventMention, personal.EventAllSingleChat}}
tests := []struct {
name string
set func(*personalConsumeOptions)
}{
{name: "subscribe-id", set: func(o *personalConsumeOptions) { o.SubscribeID = "sub" }},
{name: "rule", set: func(o *personalConsumeOptions) { o.Rule = "all" }},
{name: "event-types", set: func(o *personalConsumeOptions) { o.Common.EventTypes = []string{"x"} }},
{name: "filter", set: func(o *personalConsumeOptions) { o.Common.Filter = "x" }},
{name: "foreground", set: func(o *personalConsumeOptions) { o.Common.Foreground = true }},
{name: "force", set: func(o *personalConsumeOptions) { o.Common.Force = true }},
{name: "debug-raw-events", set: func(o *personalConsumeOptions) { o.DebugRawEvents = true }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
opts := base
test.set(&opts)
if _, err := preparePersonalMultiOptions(opts); err == nil {
t.Fatal("option succeeded")
}
})
}
}
func TestEventConsumeMultiRejectsExplicitSingleOnlyFlagsEvenWhenEmpty(t *testing.T) {
oldRun := eventRunPersonalConsume
defer func() { eventRunPersonalConsume = oldRun }()
eventRunPersonalConsume = func(*cobra.Command, personalConsumeOptions) error {
t.Fatal("personal consume ran after explicit multi-event flag")
return nil
}
flags := []string{
"--subscribe-id=",
"--rule=",
"--event-types=",
"--filter=",
"--foreground=false",
"--force=false",
"--debug-raw-events=false",
}
for _, flag := range flags {
t.Run(flag, func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{personal.EventMention, personal.EventAllSingleChat, flag})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "not supported when consuming multiple events") {
t.Fatalf("Execute() error = %v", err)
}
})
}
}
func TestRunPersonalEventConsumeManyCreatesAndCleansAllSubscriptions(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
identity := personal.Identity{AccessToken: "token", CorpID: "corp", UserID: "user", ClientID: "client", SourceID: "open"}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return identity, nil }
createdKeys := make([]string, 0, 2)
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
createdKeys = append(createdKeys, opts.EventKey)
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "all", nil
}
var states []personal.RunState
personalUpsertRunState = func(_ string, state personal.RunState) error {
states = append(states, state)
return nil
}
var deleted []string
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
deleted = append(deleted, id)
return nil
}
var removed []string
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalConsumeRunMany = func(_ context.Context, cfg consume.Config, specs []consume.ConsumerSpec) error {
if !cfg.Flatten || cfg.Projector == nil || len(specs) != 2 {
t.Fatalf("consume config/specs = %#v / %#v", cfg, specs)
}
for i, spec := range specs {
if spec.EventKey != createdKeys[i] || spec.SubscribeID != "sub-"+createdKeys[i] || !reflect.DeepEqual(spec.EventTypes, []string{createdKeys[i]}) {
t.Fatalf("spec[%d] = %#v", i, spec)
}
}
return nil
}
cmd := newPersonalCoverageCommand()
err := runPersonalEventConsume(cmd, personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
Flatten: true,
})
if err != nil {
t.Fatalf("runPersonalEventConsume() error = %v", err)
}
if len(states) != 2 || len(deleted) != 2 || len(removed) != 2 {
t.Fatalf("states=%#v deleted=%#v removed=%#v", states, deleted, removed)
}
}
func TestRunPersonalEventConsumeManyRollsBackPartialCreation(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
}
wantErr := errors.New("second subscription failed")
calls := 0
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
calls++
if calls == 2 {
return nil, "", "", wantErr
}
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "all", nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
var deleted []string
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
deleted = append(deleted, id)
return nil
}
var removed []string
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
t.Fatal("RunMany called after partial creation failure")
return nil
}
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
})
if !errors.Is(err, wantErr) {
t.Fatalf("error = %v", err)
}
if !reflect.DeepEqual(deleted, []string{"sub-first"}) || !reflect.DeepEqual(removed, []string{"sub-first"}) {
t.Fatalf("rollback deleted=%#v removed=%#v", deleted, removed)
}
}
func TestRunPersonalEventConsumeManyRejectsInvalidSubscriptionResults(t *testing.T) {
for _, test := range []struct {
name string
ensure func(int, personalConsumeOptions) *personal.Subscription
upsertErr error
wantErr string
}{
{
name: "nil subscription",
ensure: func(int, personalConsumeOptions) *personal.Subscription { return nil },
wantErr: "empty subscription",
},
{
name: "empty subscribe id",
ensure: func(int, personalConsumeOptions) *personal.Subscription { return &personal.Subscription{} },
wantErr: "empty subscribe_id",
},
{
name: "duplicate subscribe id",
ensure: func(int, personalConsumeOptions) *personal.Subscription {
return &personal.Subscription{SubscribeID: "sub-duplicate"}
},
wantErr: "duplicate subscribe_id",
},
{
name: "run state write failure",
ensure: func(_ int, opts personalConsumeOptions) *personal.Subscription {
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}
},
upsertErr: errors.New("state write failed"),
wantErr: "save run state",
},
} {
t.Run(test.name, func(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
}
calls := 0
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
calls++
return test.ensure(calls, opts), opts.EventKey, "all", nil
}
personalUpsertRunState = func(string, personal.RunState) error { return test.upsertErr }
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return nil }
personalRemoveRunStates = func(string, []string) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
t.Fatal("RunMany called with invalid subscription result")
return nil
}
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
})
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestRunPersonalEventConsumeManyDryRunDoesNotCreateSubscriptions(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
}
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
t.Fatal("dry-run created a subscription")
return nil, "", "", nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
cmd := newPersonalCoverageCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
err := runPersonalEventConsume(cmd, personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
QueryCSV: "alarm",
Common: commonConsumeOptions{DryRun: true},
})
if err != nil {
t.Fatalf("dry-run error = %v", err)
}
if !strings.Contains(stderr.String(), "subscription[0]") ||
!strings.Contains(stderr.String(), "subscription[1]") ||
!strings.Contains(stderr.String(), "filter=") {
t.Fatalf("dry-run subscriptions missing:\n%s", stderr.String())
}
}
func TestCrossPlatformCoverageRunPersonalEventConsumeManySetupAndCleanupEdges(t *testing.T) {
valid := personalConsumeOptions{EventKeys: []string{personal.EventMention, personal.EventAllSingleChat}}
t.Run("prepare error", func(t *testing.T) {
err := runPersonalEventConsumeMany(newPersonalCoverageCommand(), personalConsumeOptions{
EventKeys: []string{personal.EventMention},
})
if err == nil || !strings.Contains(err.Error(), "multiple event keys") {
t.Fatalf("error = %v", err)
}
})
t.Run("format warning and identity error", func(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
cmd := newPersonalCoverageCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
if err := cmd.Flags().Set("format", "bogus"); err != nil {
t.Fatal(err)
}
wantErr := errors.New("identity")
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{}, wantErr
}
opts := valid
opts.Common.FormatRaw = "bogus"
if err := runPersonalEventConsumeMany(cmd, opts); !errors.Is(err, wantErr) {
t.Fatalf("error = %v", err)
}
if !strings.Contains(stderr.String(), "using ndjson") {
t.Fatalf("warning = %q", stderr.String())
}
})
t.Run("flatten raw conflict", func(t *testing.T) {
cmd := newPersonalCoverageCommand()
if err := cmd.Flags().Set("format", "raw"); err != nil {
t.Fatal(err)
}
opts := valid
opts.Flatten = true
opts.Common.FormatRaw = "raw"
if err := runPersonalEventConsumeMany(cmd, opts); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("error = %v", err)
}
})
t.Run("route validation and output conflict", func(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
}
opts := valid
opts.Common.RoutesRaw = []string{"bad-route"}
if err := runPersonalEventConsumeMany(newPersonalCoverageCommand(), opts); err == nil {
t.Fatal("invalid route succeeded")
}
wantErr := errors.New("validate")
personalValidateConsumeConfig = func(consume.Config) error { return wantErr }
if err := runPersonalEventConsumeMany(newPersonalCoverageCommand(), valid); !errors.Is(err, wantErr) {
t.Fatalf("config validation error = %v", err)
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return wantErr }
cmd := newPersonalCoverageCommand()
if err := cmd.Flags().Set("output", "events.json"); err != nil {
t.Fatal(err)
}
if err := runPersonalEventConsumeMany(cmd, valid); !errors.Is(err, wantErr) {
t.Fatalf("output conflict error = %v", err)
}
})
t.Run("runtime error reports cleanup failures", func(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
}
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "all", nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
cleanupErr := errors.New("cleanup")
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return cleanupErr }
personalRemoveRunStates = func(string, []string) error { return cleanupErr }
runErr := errors.New("run many")
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error { return runErr }
cmd := newPersonalCoverageCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
if err := runPersonalEventConsumeMany(cmd, valid); !errors.Is(err, runErr) {
t.Fatalf("runtime error = %v", err)
}
if !strings.Contains(stderr.String(), "failed to clean personal subscription") ||
!strings.Contains(stderr.String(), "failed to clean personal event run state") {
t.Fatalf("cleanup warnings = %q", stderr.String())
}
})
}
func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
oldStop := personalStopConsumers
oldQuery := personalQueryStatus
oldFind := personalFindProcess
oldSignal := personalSignalProcess
defer func() {
personalStopConsumers = oldStop
personalQueryStatus = oldQuery
personalFindProcess = oldFind
personalSignalProcess = oldSignal
}()
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
return transport.ConsumerStopResp{Stopped: []string{"sub-a"}}, nil
}
personalQueryStatus = func(string) (*transport.StatusResp, error) {
t.Fatal("legacy status queried after targeted stop succeeded")
return nil, nil
}
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"sub-a"}); err != nil {
t.Fatal(err)
}
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
return transport.ConsumerStopResp{}, busctl.ErrConsumerStopUnsupported
}
personalQueryStatus = func(string) (*transport.StatusResp, error) {
return &transport.StatusResp{Consumers: []transport.StatusConsumer{{PID: 321, SubscribeID: "sub-a"}}}, nil
}
proc := &os.Process{}
personalFindProcess = func(int) (*os.Process, error) { return proc, nil }
signals := 0
personalSignalProcess = func(*os.Process, os.Signal) error { signals++; return nil }
var warning bytes.Buffer
if err := stopPersonalConsumers(&warning, "endpoint", []string{"sub-a"}); err != nil {
t.Fatal(err)
}
if signals != 1 || !strings.Contains(warning.String(), "falling back to process signal") {
t.Fatalf("signals=%d warning=%q", signals, warning.String())
}
wantErr := errors.New("targeted stop transport failed")
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
return transport.ConsumerStopResp{}, wantErr
}
personalQueryStatus = func(string) (*transport.StatusResp, error) {
t.Fatal("legacy fallback ran for a non-compatibility error")
return nil, nil
}
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"sub-a"}); !errors.Is(err, wantErr) {
t.Fatalf("transport error = %v", err)
}
}
func installPersonalManySeams(t *testing.T) func() {
t.Helper()
oldIdentity := personalResolveEventIdentity
oldLookup := personalLookupDefinition
oldEnsure := personalEnsureSubscription
oldUpsert := personalUpsertRunState
oldDelete := personalDeleteSubscription
oldRemove := personalRemoveRunStates
oldRunMany := personalConsumeRunMany
oldValidate := personalValidateConsumeConfig
oldConflict := personalValidateNoOutputConflict
return func() {
personalResolveEventIdentity = oldIdentity
personalLookupDefinition = oldLookup
personalEnsureSubscription = oldEnsure
personalUpsertRunState = oldUpsert
personalDeleteSubscription = oldDelete
personalRemoveRunStates = oldRemove
personalConsumeRunMany = oldRunMany
personalValidateConsumeConfig = oldValidate
personalValidateNoOutputConflict = oldConflict
}
}
+219 -3
View File
@@ -47,12 +47,18 @@ func TestPersonalEventListHidesSchemaIDs(t *testing.T) {
assertPersonalOutputHidesSchemaIDs(t, got)
for _, eventKey := range []string{
personal.EventFromUser,
personal.EventAllSingleChat,
personal.EventAllGroupChat,
personal.EventReadO2O,
personal.EventReadGroup,
personal.EventRecallO2O,
personal.EventRecallGroup,
personal.EventReactionO2O,
personal.EventReactionGroup,
personal.EventGroupUpdated,
personal.EventGroupMemberAdded,
personal.EventGroupMemberExited,
personal.EventGroupDisbanded,
} {
if !strings.Contains(got, eventKey) {
t.Fatalf("list output missing %s: %s", eventKey, got)
@@ -188,11 +194,50 @@ func TestPersonalEventSchemaHidesSchemaIDs(t *testing.T) {
}
}
func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
func TestPersonalEventSchemaDefaultsToTransportEnvelope(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{personal.EventSingleChat})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var doc map[string]any
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
}
if doc["jq_root_path"] != ".data | fromjson" {
t.Fatalf("jq_root_path = %#v, want .data | fromjson", doc["jq_root_path"])
}
schema, ok := doc["schema"].(map[string]any)
if !ok {
t.Fatalf("schema = %#v, want object", doc["schema"])
}
props, ok := schema["properties"].(map[string]any)
if !ok {
t.Fatalf("schema.properties = %#v, want object", schema["properties"])
}
for _, field := range []string{"type", "seq", "event_type", "data", "headers", "subscribe_id"} {
if _, ok := props[field]; !ok {
t.Fatalf("default envelope schema missing %q: %#v", field, props)
}
}
for _, field := range []string{"content", "sender", "conversation_id", "timestamp"} {
if _, ok := props[field]; ok {
t.Fatalf("default envelope schema unexpectedly contains flat field %q", field)
}
}
}
func TestPersonalEventFlattenedSchemaUsesSingleJSONSchema(t *testing.T) {
for _, eventKey := range []string{
personal.EventMention,
personal.EventSingleChat,
personal.EventInChat,
personal.EventAllSingleChat,
personal.EventAllGroupChat,
} {
t.Run(eventKey, func(t *testing.T) {
cmd := newEventSchemaCommand()
@@ -200,7 +245,7 @@ func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{eventKey})
cmd.SetArgs([]string{eventKey, "--flatten"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
@@ -228,6 +273,8 @@ func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
"message_id",
"create_time",
"event_time",
"quoted_message",
"forward_messages",
} {
if !strings.Contains(got, want) {
t.Fatalf("schema output for %s missing %q: %s", eventKey, want, got)
@@ -272,6 +319,103 @@ func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
if _, ok := props["content"].(map[string]any); !ok {
t.Fatalf("schema.properties.content = %#v, want object", props["content"])
}
quoted, ok := props["quoted_message"].(map[string]any)
if !ok || quoted["type"] != "object" {
t.Fatalf("schema.properties.quoted_message = %#v, want object", props["quoted_message"])
}
forward, ok := props["forward_messages"].(map[string]any)
if !ok || forward["type"] != "array" {
t.Fatalf("schema.properties.forward_messages = %#v, want array", props["forward_messages"])
}
})
}
}
func TestPersonalGroupLifecycleEventSchemaUsesConservativePayload(t *testing.T) {
for _, eventKey := range []string{personal.EventGroupUpdated, personal.EventGroupDisbanded} {
t.Run(eventKey, func(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{eventKey, "--flatten"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
}
if doc["event_key"] != eventKey || doc["rule_type"] != "group" || doc["jq_root_path"] != "." {
t.Fatalf("schema metadata = %#v", doc)
}
required, ok := doc["required_params"].([]any)
if !ok || len(required) != 1 || required[0] != "group" {
t.Fatalf("required_params = %#v, want [group]", doc["required_params"])
}
schema := doc["schema"].(map[string]any)
properties := schema["properties"].(map[string]any)
if len(properties) != 5 {
t.Fatalf("schema.properties = %#v, want five conservative fields", properties)
}
payload, ok := properties["payload"].(map[string]any)
if !ok || payload["type"] != "object" || payload["additionalProperties"] != true {
t.Fatalf("schema.properties.payload = %#v", properties["payload"])
}
})
}
}
func TestPersonalGroupMemberEventSchemaMatchesFlatOutput(t *testing.T) {
wantProperties := []string{
"type", "event_id", "timestamp", "subscribe_id", "conversation_id",
"operator", "operator_open_dingtalk_id", "members", "event_time",
}
for _, eventKey := range []string{personal.EventGroupMemberAdded, personal.EventGroupMemberExited} {
t.Run(eventKey, func(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{eventKey, "--flatten"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
}
if doc["event_key"] != eventKey || doc["rule_type"] != "group" || doc["jq_root_path"] != "." {
t.Fatalf("schema metadata = %#v", doc)
}
properties := doc["schema"].(map[string]any)["properties"].(map[string]any)
if len(properties) != len(wantProperties) {
t.Fatalf("schema.properties = %#v, want exactly %d flat fields", properties, len(wantProperties))
}
for _, field := range wantProperties {
if _, ok := properties[field].(map[string]any); !ok {
t.Fatalf("schema missing %q: %#v", field, properties)
}
}
members := properties["members"].(map[string]any)
items, ok := members["items"].(map[string]any)
if !ok || members["type"] != "array" || items["type"] != "object" {
t.Fatalf("members schema = %#v", members)
}
memberProperties, ok := items["properties"].(map[string]any)
if !ok {
t.Fatalf("members.items.properties = %#v", items["properties"])
}
for _, field := range []string{"nick", "open_dingtalk_id"} {
if _, ok := memberProperties[field].(map[string]any); !ok {
t.Fatalf("member schema missing %q: %#v", field, memberProperties)
}
}
if _, ok := properties["payload"]; ok {
t.Fatalf("group member schema exposed generic payload: %#v", properties)
}
})
}
}
@@ -316,7 +460,7 @@ func TestPersonalActionEventSchemaMatchesFlatOutput(t *testing.T) {
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{eventKey})
cmd.SetArgs([]string{eventKey, "--flatten"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
@@ -367,6 +511,9 @@ func TestEventSchemaDefaultsToUser(t *testing.T) {
if doc["event_key"] != personal.EventSingleChat {
t.Fatalf("event_key = %#v, want %s", doc["event_key"], personal.EventSingleChat)
}
if doc["jq_root_path"] != ".data | fromjson" {
t.Fatalf("jq_root_path = %#v, want default envelope path", doc["jq_root_path"])
}
}
func TestPersonalEventFromUserIsPubliclyAvailable(t *testing.T) {
@@ -445,6 +592,64 @@ func TestPersonalEventFromUserIsPubliclyAvailable(t *testing.T) {
}
}
func TestPersonalNewIMEventsDryRunAndValidation(t *testing.T) {
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
AccessToken: "access-1",
RefreshToken: "refresh-1",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "corp-1",
UserID: "user-1",
ClientID: "client-1",
})
t.Setenv("DWS_CONFIG_DIR", configDir)
for _, test := range []struct {
eventKey string
args []string
}{
{eventKey: personal.EventAllSingleChat},
{eventKey: personal.EventAllGroupChat},
{eventKey: personal.EventGroupUpdated, args: []string{"--group", "cid-test-group"}},
{eventKey: personal.EventGroupMemberAdded, args: []string{"--group", "cid-test-group"}},
{eventKey: personal.EventGroupMemberExited, args: []string{"--group", "cid-test-group"}},
{eventKey: personal.EventGroupDisbanded, args: []string{"--group", "cid-test-group"}},
} {
t.Run(test.eventKey, func(t *testing.T) {
if err := ensurePublicPersonalEvent(test.eventKey); err != nil {
t.Fatalf("ensurePublicPersonalEvent() error = %v", err)
}
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs(append([]string{test.eventKey}, append(test.args, "--dry-run")...))
if err := cmd.Execute(); err != nil {
t.Fatalf("dry-run Execute() error = %v", err)
}
})
}
for _, eventKey := range []string{personal.EventAllSingleChat, personal.EventAllGroupChat} {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{eventKey, "--user", "test-user-001", "--dry-run"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "--user is not supported for "+eventKey) {
t.Fatalf("%s scoped user error = %v", eventKey, err)
}
}
for _, eventKey := range []string{personal.EventGroupUpdated, personal.EventGroupMemberAdded, personal.EventGroupMemberExited, personal.EventGroupDisbanded} {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{eventKey, "--dry-run"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "--group is required for "+eventKey) {
t.Fatalf("%s missing group error = %v", eventKey, err)
}
}
}
func TestEventConsumeCobraSchemaIncludesOpenDingTalkID(t *testing.T) {
root := NewRootCommand()
root.SilenceUsage = true
@@ -469,6 +674,9 @@ func TestEventConsumeCobraSchemaIncludesOpenDingTalkID(t *testing.T) {
if _, ok := params["odid"]; ok {
t.Fatalf("schema parameters unexpectedly include odid alias: %#v", params)
}
if _, ok := params["flatten"]; !ok {
t.Fatalf("schema parameters missing flatten: %#v", params)
}
for _, name := range []string{"user", "open-dingtalk-id", "group"} {
param, ok := params[name].(map[string]any)
if !ok {
@@ -507,6 +715,14 @@ func TestEventConsumeCobraSchemaIncludesOpenDingTalkID(t *testing.T) {
t.Fatalf("schema constraint %s = %#v, missing %#v", field, groups, want)
}
assertJSONConstraintGroup("require_one_of", []string{"event_key", "subscribe-id"})
positionals, ok := doc["positionals"].([]any)
if !ok || len(positionals) != 1 {
t.Fatalf("schema positionals = %#v", doc["positionals"])
}
eventKey, ok := positionals[0].(map[string]any)
if !ok || eventKey["name"] != "event_key" || eventKey["variadic"] != true {
t.Fatalf("event_key positional = %#v, want variadic", positionals[0])
}
}
func TestPersonalEventSchemaRejectsTableFormat(t *testing.T) {
@@ -0,0 +1,121 @@
// 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 (
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
)
func TestPersonalBusProfileSelectorUsesDefaultBlankCurrentBeforeRuntimeEnrichedIdentity(t *testing.T) {
configDir, cfg, blankSelector, exactSelector := seedPersonalBusProfileSelectorConfig(t)
cfg.CurrentProfile = blankSelector
cfg.OrgCurrentProfiles = map[string]string{cfg.Profiles[0].CorpID: exactSelector}
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
authpkg.SetRuntimeProfile("")
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
identityAfterRuntimeEnrichment := personal.Identity{
CorpID: cfg.Profiles[0].CorpID,
UserID: cfg.Profiles[1].UserID,
}
if got := personalBusProfileSelector(configDir, identityAfterRuntimeEnrichment); got != blankSelector {
t.Fatalf("personalBusProfileSelector() = %q, want default blank selector %q", got, blankSelector)
}
}
func TestPersonalBusProfileSelectorUsesDefaultExactCurrent(t *testing.T) {
configDir, cfg, _, exactSelector := seedPersonalBusProfileSelectorConfig(t)
cfg.CurrentProfile = exactSelector
cfg.OrgCurrentProfiles = map[string]string{cfg.Profiles[0].CorpID: exactSelector}
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
authpkg.SetRuntimeProfile("")
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
identity := personal.Identity{CorpID: cfg.Profiles[1].CorpID, UserID: cfg.Profiles[1].UserID}
if got := personalBusProfileSelector(configDir, identity); got != exactSelector {
t.Fatalf("personalBusProfileSelector() = %q, want default exact selector %q", got, exactSelector)
}
}
func TestPersonalBusProfileSelectorPrefersExplicitRuntimeSelector(t *testing.T) {
configDir, cfg, blankSelector, _ := seedPersonalBusProfileSelectorConfig(t)
cfg.CurrentProfile = blankSelector
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
const explicitSelector = "corp_explicit:user_explicit"
authpkg.SetRuntimeProfile(explicitSelector)
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
identity := personal.Identity{CorpID: cfg.Profiles[1].CorpID, UserID: cfg.Profiles[1].UserID}
if got := personalBusProfileSelector(configDir, identity); got != explicitSelector {
t.Fatalf("personalBusProfileSelector() = %q, want explicit selector %q", got, explicitSelector)
}
}
func TestCrossPlatformCoveragePersonalBusProfileSelectorFallsBackToMatchingIdentity(t *testing.T) {
configDir, cfg, _, exactSelector := seedPersonalBusProfileSelectorConfig(t)
cfg.Profiles = append(cfg.Profiles, authpkg.Profile{
Name: "Other Current",
CorpID: "corp_event_other_fixture",
CorpName: "Other Fixture Organization",
UserID: "identity_event_other_fixture",
})
cfg.CurrentProfile = authpkg.ProfileSelectionSelector(cfg.Profiles[2], cfg)
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
authpkg.SetRuntimeProfile("")
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
identity := personal.Identity{CorpID: cfg.Profiles[1].CorpID, UserID: cfg.Profiles[1].UserID}
if got := personalBusProfileSelector(configDir, identity); got != exactSelector {
t.Fatalf("personalBusProfileSelector() = %q, want identity fallback %q", got, exactSelector)
}
}
func seedPersonalBusProfileSelectorConfig(t *testing.T) (string, *authpkg.ProfilesConfig, string, string) {
t.Helper()
configDir := t.TempDir()
cfg := &authpkg.ProfilesConfig{
Version: 2,
Profiles: []authpkg.Profile{
{
Name: "External Fixture",
CorpID: "corp_event_current_fixture",
CorpName: "Fixture Organization",
},
{
Name: "Exact Fixture",
CorpID: "corp_event_current_fixture",
CorpName: "Fixture Organization",
UserID: "identity_runtime_enriched_fixture",
},
},
}
blankSelector := authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg)
exactSelector := authpkg.ProfileSelectionSelector(cfg.Profiles[1], cfg)
if blankSelector == "" || blankSelector == cfg.Profiles[0].CorpID {
t.Fatalf("blank selector = %q, want stable account selector", blankSelector)
}
return configDir, cfg, blankSelector, exactSelector
}
+5 -1
View File
@@ -13,14 +13,18 @@ import (
func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
root := &cobra.Command{Use: "dws"}
event := newEventCommand()
markdown := &cobra.Command{Use: "markdown"}
unregistered := &cobra.Command{Use: "unregistered", Run: func(*cobra.Command, []string) {}}
root.AddCommand(event, unregistered)
root.AddCommand(event, markdown, unregistered)
hideNonDirectRuntimeCommands(root)
if event.Hidden {
t.Fatal("built-in event command was hidden by the direct-runtime visibility filter")
}
if markdown.Hidden {
t.Fatal("locally routed markdown command was hidden by the direct-runtime visibility filter")
}
if !unregistered.Hidden {
t.Fatal("control command outside the built-in/direct-runtime sets remained visible")
}
+99
View File
@@ -17,7 +17,9 @@ import (
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
// A bounded run never arms the stdin-EOF watcher, regardless of stdin
@@ -63,3 +65,100 @@ func TestPersonalBusSpawnArgs_ForwardsProfile(t *testing.T) {
}
}
}
func TestCrossPlatformCoveragePersonalBusSpawnArgsPreservesReservedBlankProfile(t *testing.T) {
configDir := t.TempDir()
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
cfg := &authpkg.ProfilesConfig{
Version: 2,
OrgCurrentProfiles: map[string]string{
"corp_event_fixture": "corp_event_fixture:identity_exact_fixture",
},
Profiles: []authpkg.Profile{
{
Name: "Fixture Organization",
CorpID: "corp_event_fixture",
CorpName: "Fixture Organization",
},
{
Name: "Exact Fixture Account",
CorpID: "corp_event_fixture",
CorpName: "Fixture Organization",
UserID: "identity_exact_fixture",
},
},
}
blankSelector := authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg)
cfg.PrimaryProfile = blankSelector
cfg.CurrentProfile = blankSelector
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
blankToken := &authpkg.TokenData{
AccessToken: "parent-blank-token",
CorpID: "corp_event_fixture",
CorpName: "Fixture Organization",
}
exactToken := &authpkg.TokenData{
AccessToken: "other-exact-token",
CorpID: "corp_event_fixture",
CorpName: "Fixture Organization",
UserID: "identity_exact_fixture",
}
if err := authpkg.SaveTokenDataKeychainForCorpID(blankToken.CorpID, blankToken); err != nil {
t.Fatalf("save parent blank token: %v", err)
}
if err := authpkg.SaveTokenDataKeychainForIdentity(exactToken.CorpID, exactToken.UserID, exactToken); err != nil {
t.Fatalf("save other exact token: %v", err)
}
// Runtime identity enrichment points at the exact sibling, but the parent
// already loaded the persisted blank current profile.
identity := personal.Identity{
CorpID: "corp_event_fixture",
UserID: "identity_exact_fixture",
SourceID: "open",
}
selector := personalBusProfileSelector(configDir, identity)
want := blankSelector
if selector != want || selector == identity.CorpID {
t.Fatalf("personalBusProfileSelector(blank) = %q, want reserved %q", selector, want)
}
args := personalBusSpawnArgs(identity, "", "", selector)
forwardedSelector := ""
for i := 0; i+1 < len(args); i++ {
if args[i] == "--profile" && args[i+1] == want {
forwardedSelector = args[i+1]
break
}
}
if forwardedSelector == "" {
t.Fatalf("spawn args did not preserve reserved blank selector: %v", args)
}
parentToken, err := authpkg.LoadTokenDataForProfile(configDir, selector)
if err != nil {
t.Fatalf("load parent token: %v", err)
}
authpkg.SetRuntimeProfile(forwardedSelector)
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
childToken, err := authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("load detached child token: %v", err)
}
if parentToken.AccessToken != blankToken.AccessToken ||
childToken.AccessToken != parentToken.AccessToken ||
childToken.UserID != "" {
t.Fatalf("parent/child token drift: parent=%#v child=%#v", parentToken, childToken)
}
authpkg.SetRuntimeProfile(want)
inferredExact := personal.Identity{
CorpID: "corp_event_fixture",
UserID: "identity_exact_fixture",
SourceID: "open",
}
if got := personalBusProfileSelector(configDir, inferredExact); got != want {
t.Fatalf("runtime blank selector changed after inferred userId: got %q, want %q", got, want)
}
}
+10 -4
View File
@@ -27,21 +27,27 @@ import (
"github.com/spf13/cobra"
)
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller) []*cobra.Command {
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller, loadUserShortcuts bool) []*cobra.Command {
injectStaticServers()
helpers.InitDeps(caller)
commands := helpers.NewPublicCommands(runner)
// Load user-defined shortcuts (~/.dws/shortcuts/*.yaml) BEFORE compiling the
// command tree, so distilled high-frequency operations mount alongside the
// built-ins. Conflicts with built-ins are skipped inside Load.
if _, err := userdef.Load(); err != nil {
slog.Warn("shortcut: failed to load user-defined shortcuts", "error", err)
if loadUserShortcuts {
if _, err := userdef.Load(); err != nil {
slog.Warn("shortcut: failed to load user-defined shortcuts", "error", err)
}
}
// Built-in + user shortcuts (`dws <service> +<command>`) share the same
// command tree; mergeTopLevelCommands folds each shortcut's service parent
// into the matching helper command so the `+leaf` sits alongside existing
// subcommands.
commands = append(commands, builtin.Commands()...)
if loadUserShortcuts {
commands = append(commands, builtin.Commands()...)
} else {
commands = append(commands, builtin.BaseCommands()...)
}
return mergeTopLevelCommands(commands)
}
+108
View File
@@ -0,0 +1,108 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"encoding/json"
"fmt"
"strings"
"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/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
const (
mcpMetaServerID = "mcp-meta"
mcpMetaURLTool = "get_mcp_server_url"
)
func newMCPURLGroup(caller edition.ToolCaller) *cobra.Command {
group := &cobra.Command{
Use: "url",
Short: "管理 MCP 服务连接地址",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return cmd.Help()
},
}
group.AddCommand(newMCPURLGetCommand(caller))
return group
}
func newMCPURLGetCommand(caller edition.ToolCaller) *cobra.Command {
cmd := &cobra.Command{
Use: "get <mcpId>",
Short: "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址",
Long: "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 " +
"Streamable HTTP 服务地址。\n\n" +
"安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用," +
"请勿分享到群聊、文档、邮件、代码仓库或日志。",
Example: " dws mcp url get 2480\n" +
" dws mcp url get 2480 --format json",
Args: cobra.ExactArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
if caller == nil {
return fmt.Errorf("MCP tool caller is not configured")
}
mcpID := strings.TrimSpace(args[0])
if mcpID == "" {
return fmt.Errorf("mcpId 不能为空")
}
result, err := caller.CallTool(cmd.Context(), mcpMetaServerID, mcpMetaURLTool, map[string]any{
"mcpId": mcpID,
})
if err != nil {
return fmt.Errorf("获取 MCP 服务地址: %w", err)
}
return writeMCPURLResult(cmd, result)
},
}
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
Name: "mcp_id",
Type: "string",
Description: "钉钉 MCP 市场中的 mcpId",
Required: true,
Index: 0,
})
return cmd
}
func writeMCPURLResult(cmd *cobra.Command, result *edition.ToolResult) error {
if result == nil {
return fmt.Errorf("MCP 元服务返回空结果")
}
// get_mcp_server_url returns one JSON document in its first non-empty text
// block. Other block types and trailing blocks are intentionally ignored.
for _, block := range result.Content {
if block.Type != "text" || strings.TrimSpace(block.Text) == "" {
continue
}
if err := apperrors.ClassifyMCPResponseText(block.Text); err != nil {
return err
}
var payload any
if err := json.Unmarshal([]byte(block.Text), &payload); err != nil {
return fmt.Errorf("MCP 元服务返回了无效 JSON: %w", err)
}
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
}
return fmt.Errorf("MCP 元服务返回空结果")
}
+194
View File
@@ -0,0 +1,194 @@
// 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"
"encoding/json"
"errors"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type mcpURLTestCaller struct {
productID string
toolName string
args map[string]any
result *edition.ToolResult
err error
}
func (c *mcpURLTestCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.productID = productID
c.toolName = toolName
c.args = args
return c.result, c.err
}
func (*mcpURLTestCaller) Format() string { return "json" }
func (*mcpURLTestCaller) DryRun() bool { return false }
func (*mcpURLTestCaller) Fields() string { return "" }
func (*mcpURLTestCaller) JQ() string { return "" }
func executeMCPURLCommand(t *testing.T, caller edition.ToolCaller, args ...string) (string, error) {
t.Helper()
root := &cobra.Command{Use: "mcp", SilenceErrors: true, SilenceUsage: true}
root.AddCommand(newMCPURLGroup(caller))
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(args)
err := root.ExecuteContext(t.Context())
return out.String(), err
}
func TestMCPURLGetCallsMetaServerAndPreservesResponse(t *testing.T) {
const response = `{"result":{"mcpURL":"https://example.test/mcp?key=one&token=two","mcpJSON":{"transport":"streamable-http"},"name":"Example"}}`
caller := &mcpURLTestCaller{
result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}},
}
out, err := executeMCPURLCommand(t, caller, "url", "get", " 10043 ")
if err != nil {
t.Fatalf("execute mcp url get: %v", err)
}
if caller.productID != mcpMetaServerID {
t.Fatalf("productID = %q, want %q", caller.productID, mcpMetaServerID)
}
if caller.toolName != mcpMetaURLTool {
t.Fatalf("toolName = %q, want %q", caller.toolName, mcpMetaURLTool)
}
if got := caller.args["mcpId"]; got != "10043" {
t.Fatalf("mcpId = %#v, want %q", got, "10043")
}
var payload map[string]any
if err := json.Unmarshal([]byte(out), &payload); err != nil {
t.Fatalf("output is not JSON: %v\n%s", err, out)
}
result, ok := payload["result"].(map[string]any)
if !ok {
t.Fatalf("output result = %#v", payload["result"])
}
if got := result["mcpURL"]; got != "https://example.test/mcp?key=one&token=two" {
t.Fatalf("result.mcpURL = %#v", got)
}
}
func TestMCPURLGetRejectsBlankID(t *testing.T) {
_, err := executeMCPURLCommand(t, &mcpURLTestCaller{}, "url", "get", " ")
if err == nil || !strings.Contains(err.Error(), "mcpId 不能为空") {
t.Fatalf("error = %v, want blank mcpId error", err)
}
}
func TestMCPURLGroupShowsHelp(t *testing.T) {
out, err := executeMCPURLCommand(t, nil, "url")
if err != nil {
t.Fatalf("execute mcp url: %v", err)
}
if !strings.Contains(out, "get") {
t.Fatalf("help output does not list get command:\n%s", out)
}
}
func TestMCPURLGetRejectsMissingCaller(t *testing.T) {
_, err := executeMCPURLCommand(t, nil, "url", "get", "10043")
if err == nil || !strings.Contains(err.Error(), "caller is not configured") {
t.Fatalf("error = %v, want missing caller error", err)
}
}
func TestMCPURLGetPropagatesCallError(t *testing.T) {
caller := &mcpURLTestCaller{err: errors.New("permission denied")}
_, err := executeMCPURLCommand(t, caller, "url", "get", "10043")
if err == nil || !strings.Contains(err.Error(), "permission denied") {
t.Fatalf("error = %v, want call error", err)
}
}
func TestMCPURLGetRejectsInvalidJSON(t *testing.T) {
caller := &mcpURLTestCaller{
result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: "not-json"}}},
}
_, err := executeMCPURLCommand(t, caller, "url", "get", "10043")
if err == nil || !strings.Contains(err.Error(), "无效 JSON") {
t.Fatalf("error = %v, want invalid JSON error", err)
}
}
func TestMCPURLGetRejectsEmptyResults(t *testing.T) {
tests := []struct {
name string
result *edition.ToolResult
}{
{name: "nil result"},
{
name: "no usable text content",
result: &edition.ToolResult{Content: []edition.ContentBlock{
{Type: "image", Text: "ignored"},
{Type: "text", Text: " "},
}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &mcpURLTestCaller{result: tt.result}
_, err := executeMCPURLCommand(t, caller, "url", "get", "10043")
if err == nil || !strings.Contains(err.Error(), "返回空结果") {
t.Fatalf("error = %v, want empty result error", err)
}
})
}
}
func TestMCPURLGetClassifiesBusinessError(t *testing.T) {
caller := &mcpURLTestCaller{
result: &edition.ToolResult{Content: []edition.ContentBlock{{
Type: "text",
Text: `{"success":false,"errorMsg":"搜索内容不能为空"}`,
}}},
}
_, err := executeMCPURLCommand(t, caller, "url", "get", "10043")
if err == nil {
t.Fatal("expected classified business error")
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "business_error" {
t.Fatalf("error = %#v, want classified business error", err)
}
}
func TestRootRegistersMCPURLGet(t *testing.T) {
root := NewRootCommand(t.Context())
mcp, _, err := root.Find([]string{"mcp"})
if err != nil {
t.Fatalf("find mcp: %v", err)
}
if mcp.Hidden {
t.Fatal("mcp command must be public when it contains reviewed public helpers")
}
cmd, _, err := root.Find([]string{"mcp", "url", "get"})
if err != nil {
t.Fatalf("find mcp url get: %v", err)
}
if got := cmd.CommandPath(); got != "dws mcp url get" {
t.Fatalf("command path = %q, want %q", got, "dws mcp url get")
}
}
+39
View File
@@ -107,6 +107,45 @@ func TestRuntimeRunnerDeduplicatesByResolvedIdentityInSameCorp(t *testing.T) {
}
}
func TestCrossPlatformCoverageRuntimeRunnerDeduplicatesReservedAndOrganizationAliasesForBlankProfile(t *testing.T) {
exact := authLogoutTestToken("corp_blank_alias")
exact.UserID = "identity_exact_alias"
configDir := setupAuthLogoutProfiles(t, exact)
blank := authLogoutTestToken("corp_blank_alias")
blank.AccessToken = "access-unresolved-alias"
blank.RefreshToken = "refresh-unresolved-alias"
blank.UserID = ""
blank.UserName = ""
if err := authpkg.SaveTokenData(configDir, blank); err != nil {
t.Fatalf("SaveTokenData(blank) error = %v", err)
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
var reserved string
for _, profile := range cfg.Profiles {
if profile.CorpID == blank.CorpID && profile.UserID == "" {
reserved = authpkg.ProfileSelectionSelector(profile, cfg)
break
}
}
if reserved == "" || reserved == blank.CorpID {
t.Fatalf("blank selector = %q, want reserved selector", reserved)
}
selections, multi, err := resolveMultiProfileSelections(configDir, reserved+","+blank.CorpID)
if err != nil {
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
}
if !multi || len(selections) != 1 {
t.Fatalf("blank aliases = multi %v selections %#v, want one identity", multi, selections)
}
if selections[0].Selector != reserved || selections[0].Profile.UserID != "" {
t.Fatalf("blank selection = %#v, want first reserved alias preserved", selections[0])
}
}
func TestRuntimeRunnerKeepsSingleProfileBehavior(t *testing.T) {
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
authpkg.SetRuntimeProfile("corp_a")
+3 -2
View File
@@ -61,7 +61,7 @@ var (
patPollDeviceFlowWithInterval = pollPatDeviceFlowWithInterval
patSaveAppConfig = authpkg.SaveAppConfig
patExchangeCodeForToken = authpkg.ExchangeCodeForToken
patSaveTokenData = authpkg.SaveTokenData
patSaveTokenData = authpkg.SaveLoginTokenData
patSleep = time.Sleep
patPollHTTPDo = (*http.Client).Do
patPollNewRequest = http.NewRequestWithContext
@@ -570,7 +570,8 @@ func handlePatAuthCheck(
// or when flowId is absent, the CLI returns machine-readable JSON to
// stderr and leaves UI/polling/retry to the host. `claw-type` is NOT
// used for this decision — it is only forwarded on the wire via
// edition.MergeHeaders and surfaced in hostControl for traceability.
// the edition default / DWS_AGENT_PRODUCT override and surfaced in
// hostControl for traceability.
if hostOwnedPAT || patData.Data.FlowID == "" {
if hostOwnedPAT {
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorForHostControl(patErr.RawJSON)}
+5 -3
View File
@@ -30,6 +30,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/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
)
func TestIsPatScopeError_MissingScope(t *testing.T) {
@@ -1006,10 +1007,11 @@ func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", tmpDir)
// Host-owned decision: driven ONLY by DINGTALK_DWS_AGENTCODE.
// DINGTALK_AGENT is set to demonstrate it does NOT leak into
// hostControl.clawType — the open-source build pins that to the
// literal edition.DefaultOSSClawType value ("openClaw").
// hostControl.clawType. With no DWS_AGENT_PRODUCT override the
// open-source edition default remains "openClaw".
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
t.Setenv("DINGTALK_AGENT", "sales-copilot")
t.Setenv(agentproduct.EnvName, "")
mock := &mockRunner{
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
@@ -1053,7 +1055,7 @@ func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
}
hostControl, _ := data["hostControl"].(map[string]any)
if got, _ := hostControl["clawType"].(string); got != "openClaw" {
t.Fatalf("hostControl.clawType = %q, want openClaw (hard-wired by open-source edition)", got)
t.Fatalf("hostControl.clawType = %q, want openClaw (open-source edition default)", got)
}
if got, _ := hostControl["callbackOwner"].(string); got != "host" {
t.Fatalf("hostControl.callbackOwner = %q, want host", got)
+14 -13
View File
@@ -27,11 +27,11 @@ import (
//
// Decision rule:
// - Host-owned is triggered iff DINGTALK_DWS_AGENTCODE is non-empty.
// - When triggered, `clawType` in the emitted hostControl block MUST
// be the exact value the CLI actually injects on the wire into the
// `claw-type` HTTP header. The open-source build pins that to
// edition.DefaultOSSClawType ("openClaw") unconditionally — there
// is no per-spawn env override.
// - When triggered, `clawType` in the emitted hostControl block MUST be the
// exact value the CLI actually injects on the wire. Each edition supplies
// its existing default and an optional valid DWS_AGENT_PRODUCT overrides
// it. Invalid input falls back here for library compatibility; root command
// execution rejects it before network access.
// - When DINGTALK_DWS_AGENTCODE is empty the provider returns "" so
// HostControlBlock yields nil and no hostControl block is emitted.
func init() {
@@ -48,15 +48,16 @@ func hostControlProviderFromEnv() string {
return effectiveClawType()
}
// effectiveClawType returns the literal value that MergeHeaders will
// inject into outbound `claw-type` headers. Going through the edition
// hook (instead of a hard-coded constant) keeps this site correct for
// downstream editions that override MergeHeaders.
// effectiveClawType resolves the literal value injected into outbound
// `claw-type` headers without invoking credential hooks from PAT error
// serialization. MergeHeaders implementations that set claw-type must satisfy
// the edition contract that this value is independent of the base map.
func effectiveClawType() string {
if h := edition.Get(); h != nil && h.MergeHeaders != nil {
if v, ok := h.MergeHeaders(map[string]string{})["claw-type"]; ok && v != "" {
return v
headers := make(map[string]string)
if h := edition.Get(); h != nil {
if h.MergeHeaders != nil {
headers = h.MergeHeaders(headers)
}
}
return edition.DefaultOSSClawType
return resolveEffectiveAgentProduct(headers)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,675 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
type pluginFailRunner struct{}
func (pluginFailRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
return executor.Result{}, errors.New("runner failed")
}
type pluginWrongFlagValue struct{}
func (pluginWrongFlagValue) String() string { return "" }
func (pluginWrongFlagValue) Set(string) error { return nil }
func (pluginWrongFlagValue) Type() string { return "wrong" }
func TestPluginCompilerRejectsInvalidDuplicateAndEmptyDefinitions(t *testing.T) {
invalidRoot := conferencePluginDescriptor()
invalidRoot.CLI.Command = "Invalid Root"
if commands := buildPluginCommands([]mcptypes.ServerDescriptor{invalidRoot}, executor.EchoRunner{}, nil); len(commands) != 0 {
t.Fatalf("invalid root produced commands %#v", commands)
}
descriptor := conferencePluginDescriptor()
descriptor.CLI.Groups = map[string]mcptypes.CLIGroupDef{
"empty": {Description: "removed when no leaf survives"},
}
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"": {CLIName: "blank-tool"},
"hidden": {CLIName: "hidden", Hidden: true},
"invalid": {CLIName: "Invalid Leaf"},
"first": {CLIName: "same"},
"second": {CLIName: "same"},
}
commands := buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, executor.EchoRunner{}, nil)
if len(commands) != 1 {
t.Fatalf("commands = %#v", commands)
}
if requireOptionalPluginChild(commands[0], "same") == nil {
t.Fatal("valid leaf was not retained")
}
if requireOptionalPluginChild(commands[0], "empty") != nil {
t.Fatal("empty group was not pruned")
}
empty := conferencePluginDescriptor()
empty.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"hidden": {CLIName: "hidden", Hidden: true},
}
if commands := buildPluginCommands([]mcptypes.ServerDescriptor{empty}, executor.EchoRunner{}, nil); len(commands) != 0 {
t.Fatalf("empty overlay produced commands %#v", commands)
}
}
func TestPluginLeafExecutionErrorsAndBodyWrapper(t *testing.T) {
base := conferencePluginDescriptor()
base.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"wrapped": {
CLIName: "wrapped",
BodyWrapper: "body",
Flags: map[string]mcptypes.CLIFlagOverride{
"value": {Required: true},
},
},
}
runner := &pluginCaptureRunner{}
root := pluginTestRoot(buildPluginCommands([]mcptypes.ServerDescriptor{base}, runner, nil)...)
root.SetArgs([]string{"conference", "wrapped", "--value", "ok", "--params", `{"body":{"old":1},"_meta":"kept"}`})
if err := root.Execute(); err != nil {
t.Fatalf("wrapped command: %v", err)
}
want := map[string]any{
"_meta": "kept",
"body": map[string]any{"old": float64(1), "value": "ok"},
}
if !reflect.DeepEqual(runner.invocations[0].Params, want) {
t.Fatalf("wrapped params = %#v, want %#v", runner.invocations[0].Params, want)
}
for _, testCase := range []struct {
name string
runner executor.Runner
args []string
}{
{name: "invalid json", runner: executor.EchoRunner{}, args: []string{"conference", "wrapped", "--json", "["}},
{name: "missing required", runner: executor.EchoRunner{}, args: []string{"conference", "wrapped"}},
{name: "missing runner", runner: nil, args: []string{"conference", "wrapped", "--value", "ok"}},
{name: "runner error", runner: pluginFailRunner{}, args: []string{"conference", "wrapped", "--value", "ok"}},
} {
t.Run(testCase.name, func(t *testing.T) {
commandRoot := pluginTestRoot(buildPluginCommands([]mcptypes.ServerDescriptor{base}, testCase.runner, nil)...)
commandRoot.SetArgs(testCase.args)
if err := commandRoot.Execute(); err == nil {
t.Fatal("expected command error")
}
})
}
for _, flagName := range []string{"json", "params"} {
t.Run("unreadable "+flagName, func(t *testing.T) {
commands := buildPluginCommands([]mcptypes.ServerDescriptor{base}, executor.EchoRunner{}, nil)
leaf := requirePluginChild(t, commands[0], "wrapped")
leaf.Flags().Lookup(flagName).Value = pluginWrongFlagValue{}
commandRoot := pluginTestRoot(commands...)
commandRoot.SetArgs([]string{"conference", "wrapped", "--value", "ok"})
if err := commandRoot.Execute(); err == nil {
t.Fatal("expected unreadable flag error")
}
})
}
}
func TestPluginBindingCompilerCoversAliasesAndPositionalValidators(t *testing.T) {
reservations := pluginFlagReservations{
names: map[string]bool{"reserved": true},
shorthands: map[string]bool{},
}
bindings, _, _, ok := registerPluginBindings("alias", mcptypes.CLIToolOverride{
Flags: map[string]mcptypes.CLIFlagOverride{
"value": {Alias: "value", Aliases: []string{"", "Bad", "value", "other"}},
},
}, reservations)
if !ok || !reflect.DeepEqual(bindings[0].names, []string{"value", "other"}) {
t.Fatalf("alias bindings = (%#v, %v)", bindings, ok)
}
if _, _, _, ok := registerPluginBindings("conflict", mcptypes.CLIToolOverride{
Flags: map[string]mcptypes.CLIFlagOverride{"value": {Alias: "reserved"}},
}, reservations); ok {
t.Fatal("reserved flag was accepted")
}
if _, _, _, ok := registerPluginBindings("negative", mcptypes.CLIToolOverride{
Flags: map[string]mcptypes.CLIFlagOverride{"value": {Positional: true, PositionalIndex: -1}},
}, reservations); ok {
t.Fatal("negative positional index was accepted")
}
if _, _, _, ok := registerPluginBindings("duplicate", mcptypes.CLIToolOverride{
Flags: map[string]mcptypes.CLIFlagOverride{
"first": {Positional: true, PositionalIndex: 0},
"second": {Positional: true, PositionalIndex: 0},
},
}, reservations); ok {
t.Fatal("duplicate positional index was accepted")
}
if _, _, _, ok := registerPluginBindings("gap", mcptypes.CLIToolOverride{
Flags: map[string]mcptypes.CLIFlagOverride{
"second": {Positional: true, PositionalIndex: 1},
},
}, reservations); ok {
t.Fatal("non-contiguous positional indexes were accepted")
}
for _, testCase := range []struct {
name string
flags map[string]mcptypes.CLIFlagOverride
wantUse string
valid []string
invalid []string
}{
{
name: "exact",
flags: map[string]mcptypes.CLIFlagOverride{
"second": {Positional: true, PositionalIndex: 1, Required: true},
"first": {Positional: true, PositionalIndex: 0, Required: true},
},
wantUse: "exact [first] [second]", valid: []string{"a", "b"}, invalid: []string{"a"},
},
{
name: "range",
flags: map[string]mcptypes.CLIFlagOverride{
"first": {Positional: true, PositionalIndex: 0, Required: true},
"second": {Positional: true, PositionalIndex: 1},
},
wantUse: "range [first] [second]", valid: []string{"a"}, invalid: []string{},
},
{
name: "maximum",
flags: map[string]mcptypes.CLIFlagOverride{
"first": {Positional: true, PositionalIndex: 0},
"second": {Positional: true, PositionalIndex: 1},
},
wantUse: "maximum [first] [second]", valid: []string{}, invalid: []string{"a", "b", "c"},
},
} {
t.Run(testCase.name, func(t *testing.T) {
_, use, validator, ok := registerPluginBindings(testCase.name, mcptypes.CLIToolOverride{Flags: testCase.flags}, reservations)
if !ok || use != testCase.wantUse {
t.Fatalf("binding contract = (%q, %v)", use, ok)
}
cmd := &cobra.Command{Use: testCase.name}
if err := validator(cmd, testCase.valid); err != nil {
t.Fatalf("valid args: %v", err)
}
if err := validator(cmd, testCase.invalid); err == nil {
t.Fatal("invalid args were accepted")
}
})
}
}
func TestPluginFlagRegistrationAndReadingCoversAllKinds(t *testing.T) {
cmd := &cobra.Command{Use: "leaf"}
override := mcptypes.CLIToolOverride{Flags: map[string]mcptypes.CLIFlagOverride{
"integer": {Default: "2", Shorthand: "i", Hidden: true},
"float": {Default: "1.5"},
"boolean": {Default: "true"},
"slice": {Default: "one, ,two"},
"json": {Default: `{"old":true}`},
"string": {Default: "text"},
}}
bindings := []pluginFlagBinding{
{property: "integer", names: []string{"integer", "integer-alias"}, kind: pluginFlagInt},
{property: "float", names: []string{"float"}, kind: pluginFlagFloat},
{property: "boolean", names: []string{"boolean"}, kind: pluginFlagBool},
{property: "slice", names: []string{"slice"}, kind: pluginFlagStringSlice},
{property: "json", names: []string{"json-value"}, kind: pluginFlagJSON},
{property: "string", names: []string{"string"}, kind: pluginFlagString},
}
registerPluginFlags(cmd, bindings, override, pluginFlagReservations{shorthands: map[string]bool{}})
for name, raw := range map[string]string{
"integer": "3", "float": "2.5", "boolean": "false",
"slice": "three,four", "json-value": `{"ok":true}`, "string": "changed",
} {
if err := cmd.Flags().Set(name, raw); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
wants := map[string]any{
"integer": 3,
"float": 2.5,
"boolean": false,
"slice": []string{"three", "four"},
"json": map[string]any{"ok": true},
"string": "changed",
}
for _, binding := range bindings {
value, err := readPluginFlag(cmd.Flags(), binding.names[0], binding.kind)
if err != nil || !reflect.DeepEqual(value, wants[binding.property]) {
t.Fatalf("read %s = (%#v, %v), want %#v", binding.property, value, err, wants[binding.property])
}
}
if !cmd.Flags().Lookup("integer").Hidden || !cmd.Flags().Lookup("integer-alias").Hidden {
t.Fatal("hidden primary or alias flag was exposed")
}
if err := cmd.Flags().Set("json-value", "{"); err != nil {
t.Fatal(err)
}
if _, err := readPluginFlag(cmd.Flags(), "json-value", pluginFlagJSON); err == nil {
t.Fatal("invalid JSON flag was accepted")
}
cmd.Flags().Lookup("json-value").Value = pluginWrongFlagValue{}
if _, err := readPluginFlag(cmd.Flags(), "json-value", pluginFlagJSON); err == nil {
t.Fatal("wrong JSON flag type was accepted")
}
}
func TestCollectPluginBindingsCoversEveryValueSourceAndFailure(t *testing.T) {
t.Run("sources", func(t *testing.T) {
cmd := &cobra.Command{Use: "leaf"}
registerPluginFlag(cmd.Flags(), "flag", "", "", pluginFlagString, "")
if err := cmd.Flags().Set("flag", "from-flag"); err != nil {
t.Fatal(err)
}
t.Setenv("PLUGIN_COVERAGE_ENV", "7")
params := map[string]any{"existing": "from-json"}
bindings := []pluginFlagBinding{
{property: "flag", names: []string{"flag"}, kind: pluginFlagString},
{property: "existing", kind: pluginFlagString},
{property: "positional", kind: pluginFlagBool, positional: true, positionalIndex: 0},
{property: "default", kind: pluginFlagFloat, defaultProvided: true, defaultValue: "1.5"},
{property: "env", kind: pluginFlagInt, envDefault: "PLUGIN_COVERAGE_ENV"},
{property: "optional", kind: pluginFlagString},
}
if err := collectPluginBindings(cmd, []string{"true"}, bindings, params); err != nil {
t.Fatal(err)
}
want := map[string]any{
"flag": "from-flag", "existing": "from-json", "positional": true,
"default": 1.5, "env": 7,
}
if !reflect.DeepEqual(params, want) {
t.Fatalf("params = %#v, want %#v", params, want)
}
})
for _, testCase := range []struct {
name string
prepare func(t *testing.T, cmd *cobra.Command)
args []string
binding pluginFlagBinding
params map[string]any
}{
{
name: "wrong flag type",
prepare: func(t *testing.T, cmd *cobra.Command) {
cmd.Flags().String("value", "", "")
if err := cmd.Flags().Set("value", "x"); err != nil {
t.Fatal(err)
}
},
binding: pluginFlagBinding{property: "value", names: []string{"value"}, kind: pluginFlagInt},
},
{name: "invalid positional", args: []string{"maybe"}, binding: pluginFlagBinding{property: "value", kind: pluginFlagBool, positional: true, positionalIndex: 0}},
{name: "invalid default", binding: pluginFlagBinding{property: "value", kind: pluginFlagInt, defaultProvided: true, defaultValue: "bad"}},
{
name: "invalid env",
prepare: func(t *testing.T, _ *cobra.Command) { t.Setenv("PLUGIN_COVERAGE_BAD_ENV", "bad") },
binding: pluginFlagBinding{property: "value", kind: pluginFlagInt, envDefault: "PLUGIN_COVERAGE_BAD_ENV"},
},
{name: "missing named required", binding: pluginFlagBinding{property: "value", names: []string{"value"}, required: true}},
{name: "missing positional required", binding: pluginFlagBinding{property: "value", required: true, positional: true, positionalIndex: 0}},
{name: "required omitted", binding: pluginFlagBinding{property: "value", required: true, defaultProvided: true, defaultValue: "", omitWhen: "empty"}},
} {
t.Run(testCase.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "leaf"}
if testCase.prepare != nil {
testCase.prepare(t, cmd)
}
if err := collectPluginBindings(cmd, testCase.args, []pluginFlagBinding{testCase.binding}, testCase.params); err == nil {
t.Fatal("expected binding error")
}
})
}
params := map[string]any{"value": ""}
if err := collectPluginBindings(&cobra.Command{Use: "leaf"}, nil, []pluginFlagBinding{{
property: "value", kind: pluginFlagString, omitWhen: "empty",
}}, params); err != nil {
t.Fatal(err)
}
if _, exists := params["value"]; exists {
t.Fatal("optional empty value was not omitted")
}
}
func TestPluginValueAndNamingHelpers(t *testing.T) {
parseCases := []struct {
kind pluginFlagKind
raw string
want any
}{
{pluginFlagInt, " 2 ", 2},
{pluginFlagFloat, " 2.5 ", 2.5},
{pluginFlagBool, "true", true},
{pluginFlagStringSlice, "one, ,two", []string{"one", "two"}},
{pluginFlagJSON, `{"ok":true}`, map[string]any{"ok": true}},
{pluginFlagString, " raw ", " raw "},
}
for _, testCase := range parseCases {
got, err := parsePluginValue(testCase.raw, testCase.kind)
if err != nil || !reflect.DeepEqual(got, testCase.want) {
t.Fatalf("parse %q = (%#v, %v), want %#v", testCase.raw, got, err, testCase.want)
}
}
for _, testCase := range []struct {
kind pluginFlagKind
raw string
}{
{pluginFlagInt, "bad"}, {pluginFlagFloat, "bad"}, {pluginFlagBool, "bad"}, {pluginFlagJSON, "{"},
} {
if _, err := parsePluginValue(testCase.raw, testCase.kind); err == nil {
t.Fatalf("invalid %q was accepted", testCase.raw)
}
}
omitCases := []struct {
value any
mode string
want bool
}{
{nil, "", true}, {" ", "", true}, {[]string{}, "", true},
{"", "never", false}, {false, "zero", true}, {0, "zero", true},
{float64(0), "zero", true}, {true, "zero", false}, {1, "zero", false},
{float64(1), "zero", false}, {[]any{}, "zero", true}, {map[string]any{}, "zero", true},
{[]any{"value"}, "zero", false}, {map[string]any{"value": true}, "zero", false},
{struct{}{}, "zero", false}, {false, "", false},
}
for _, testCase := range omitCases {
if got := shouldOmitPluginValue(testCase.value, testCase.mode); got != testCase.want {
t.Fatalf("omit (%#v, %q) = %v, want %v", testCase.value, testCase.mode, got, testCase.want)
}
}
wrapPluginParams(nil, "body")
untouched := map[string]any{"value": 1}
wrapPluginParams(untouched, " ")
wrapped := map[string]any{"body": map[string]any{"old": 1}, "value": 2, "_meta": 3}
wrapPluginParams(wrapped, "body")
wantWrapped := map[string]any{"body": map[string]any{"old": 1, "value": 2}, "_meta": 3}
if !reflect.DeepEqual(wrapped, wantWrapped) {
t.Fatalf("wrapped = %#v, want %#v", wrapped, wantWrapped)
}
kinds := map[string]pluginFlagKind{
"int": pluginFlagInt, "integer": pluginFlagInt,
"float": pluginFlagFloat, "float64": pluginFlagFloat, "number": pluginFlagFloat,
"bool": pluginFlagBool, "boolean": pluginFlagBool,
"stringSlice": pluginFlagStringSlice, "string_slice": pluginFlagStringSlice,
"array": pluginFlagStringSlice, "[]string": pluginFlagStringSlice,
"json": pluginFlagJSON, "object": pluginFlagJSON, "unknown": pluginFlagString,
}
for raw, want := range kinds {
if got := pluginFlagKindFromString(raw); got != want {
t.Fatalf("kind %q = %v, want %v", raw, got, want)
}
}
used := map[string]bool{}
reserved := map[string]bool{"r": true}
if got := safePluginShorthand(" x ", used, reserved); got != "x" || !used["x"] {
t.Fatalf("safe shorthand = %q / %#v", got, used)
}
for _, raw := range []string{"", "xy", "x", "r"} {
if got := safePluginShorthand(raw, used, reserved); got != "" {
t.Fatalf("unsafe shorthand %q = %q", raw, got)
}
}
baseReservations := pluginReservedFlags(nil)
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().StringP("custom", "c", "", "")
rootReservations := pluginReservedFlags(root)
if !baseReservations.names["yes"] || !rootReservations.names["custom"] || !rootReservations.shorthands["c"] {
t.Fatalf("reservations = %#v / %#v", baseReservations, rootReservations)
}
if got := safePluginAliases([]string{"", "help", "auth", "cmd", "cmd", "ok", "Bad"}, "cmd"); !reflect.DeepEqual(got, []string{"ok"}) {
t.Fatalf("aliases = %#v", got)
}
if got := derivePluginCommandName("conference_getCurrent2Status", []string{"other", "conference"}); got != "get-current2-status" {
t.Fatalf("derived name = %q", got)
}
if got := pluginKebabName(" HTTP2.Foo_bar baz@ "); got != "http2-foo-bar-baz@" {
t.Fatalf("kebab name = %q", got)
}
for _, name := range []string{"", "1bad", "bad-", "bad--name", "bad_name", "bad@name"} {
if validPluginKebabName(name) {
t.Fatalf("invalid kebab name %q was accepted", name)
}
}
if !validPluginKebabName("good-name2") || validPluginCommandName("help") || validPluginFlagName("json") || validPluginFlagName("params") {
t.Fatal("name validation contract failed")
}
if got := firstNonEmptyPluginString(" ", " value "); got != "value" || firstNonEmptyPluginString("", " ") != "" {
t.Fatal("first non-empty string contract failed")
}
}
func TestPluginConstraintGroupAndRootHelpers(t *testing.T) {
cmd := &cobra.Command{Use: "leaf"}
for _, name := range []string{"a", "b", "c"} {
cmd.Flags().String(name, "", "")
}
applyPluginFlagConstraints(cmd, mcptypes.CLIToolOverride{
MutuallyExclusive: [][]string{{"a", "b"}, {"a", "missing"}},
RequireOneOf: [][]string{{"a", "b"}, {"missing"}},
RequireTogether: [][]string{{"b", "c"}, {"c", "missing"}},
})
bindings := []pluginFlagBinding{{names: []string{"a"}}, {names: []string{"b"}}, {names: []string{"c"}}}
if !validPluginFlagConstraints(bindings, mcptypes.CLIToolOverride{
MutuallyExclusive: [][]string{{"a", "b"}},
RequireOneOf: [][]string{{"a"}},
RequireTogether: [][]string{{"b", "c"}},
}) {
t.Fatal("valid plugin constraints were rejected")
}
for _, invalid := range []mcptypes.CLIToolOverride{
{MutuallyExclusive: [][]string{{"a"}}},
{RequireOneOf: [][]string{{"missing"}}},
{RequireTogether: [][]string{{"a", "a"}}},
} {
if validPluginFlagConstraints(bindings, invalid) {
t.Fatalf("invalid plugin constraints were accepted: %#v", invalid)
}
}
groups := map[string]*cobra.Command{}
root := &cobra.Command{Use: "root"}
group := ensurePluginGroup(root, "parent.child", "child description", groups)
if group.Name() != "child" || group.Short != "child description" || !cmdutil.IsPluginSourced(group) {
t.Fatalf("group = %#v", group)
}
if again := ensurePluginGroup(root, "parent.child", "ignored", groups); again != group {
t.Fatal("existing group was not reused")
}
for _, invalid := range []string{"safe.bad_name", "_bad", ".parent", "parent."} {
if got := ensurePluginGroup(root, invalid, "invalid", groups); got != nil {
t.Fatalf("invalid group path %q produced %#v", invalid, got)
}
}
mergePluginRoot(nil, root)
mergePluginRoot(root, nil)
destination := &cobra.Command{Use: "plugin", Aliases: []string{"one"}}
source := &cobra.Command{Use: "plugin", Aliases: []string{"one", "two"}}
source.AddCommand(&cobra.Command{Use: "leaf"})
mergePluginRoot(destination, source)
if !reflect.DeepEqual(destination.Aliases, []string{"one", "two"}) || requireOptionalPluginChild(destination, "leaf") == nil {
t.Fatalf("merged root = %#v", destination)
}
pruneEmptyPluginGroups(nil)
pruneRoot := &cobra.Command{Use: "root"}
empty := cobracmd.NewGroupCommand("empty", "empty")
nonEmpty := cobracmd.NewGroupCommand("non-empty", "non-empty")
nonEmpty.AddCommand(&cobra.Command{Use: "leaf"})
pruneRoot.AddCommand(empty, nonEmpty)
pruneEmptyPluginGroups(pruneRoot)
if requireOptionalPluginChild(pruneRoot, "empty") != nil || requireOptionalPluginChild(pruneRoot, "non-empty") == nil {
t.Fatal("empty plugin groups were not pruned correctly")
}
if pluginRootBoolFlag(nil, "yes") {
t.Fatal("nil command reported a root flag")
}
noFlag := &cobra.Command{Use: "root"}
if pluginRootBoolFlag(noFlag, "yes") {
t.Fatal("missing flag reported true")
}
wrongType := &cobra.Command{Use: "root"}
wrongType.PersistentFlags().String("yes", "true", "")
if pluginRootBoolFlag(wrongType, "yes") {
t.Fatal("wrong flag type reported true")
}
boolRoot := &cobra.Command{Use: "root"}
boolRoot.PersistentFlags().Bool("yes", false, "")
if err := boolRoot.PersistentFlags().Set("yes", "true"); err != nil {
t.Fatal(err)
}
if !pluginRootBoolFlag(boolRoot, "yes") {
t.Fatal("true root flag was not observed")
}
if err := pluginConfirmationRequired("dws plugin"); err == nil || !strings.Contains(err.Error(), "sensitive") {
t.Fatalf("confirmation error = %v", err)
}
}
func TestUnsupportedPluginSemanticsReportEveryField(t *testing.T) {
overlays := []struct {
value mcptypes.CLIOverlay
want string
}{
{mcptypes.CLIOverlay{Parent: "root"}, "parent"},
{mcptypes.CLIOverlay{Group: "group"}, "group"},
{mcptypes.CLIOverlay{ServerDeps: []string{"other"}}, "serverDeps"},
{mcptypes.CLIOverlay{Hints: map[string]json.RawMessage{"x": json.RawMessage(`{}`)}}, "hintCommands"},
{mcptypes.CLIOverlay{RedirectTo: "other"}, "redirectTo"},
{mcptypes.CLIOverlay{}, ""},
}
for _, testCase := range overlays {
if got := unsupportedPluginOverlay(testCase.value); got != testCase.want {
t.Fatalf("unsupported overlay = %q, want %q", got, testCase.want)
}
}
tools := []struct {
value mcptypes.CLIToolOverride
want string
}{
{mcptypes.CLIToolOverride{CLIAliases: []string{"x"}}, "cliAliases"},
{mcptypes.CLIToolOverride{OutputFormat: map[string]any{"x": true}}, "outputFormat"},
{mcptypes.CLIToolOverride{ServerOverride: "other"}, "serverOverride"},
{mcptypes.CLIToolOverride{RedirectTo: "x"}, "redirectTo"},
{mcptypes.CLIToolOverride{Pipeline: []json.RawMessage{json.RawMessage(`{}`)}}, "pipeline"},
{mcptypes.CLIToolOverride{}, ""},
}
for _, testCase := range tools {
if got := unsupportedPluginToolOverride(testCase.value); got != testCase.want {
t.Fatalf("unsupported tool = %q, want %q", got, testCase.want)
}
}
flags := []struct {
value mcptypes.CLIFlagOverride
want string
}{
{mcptypes.CLIFlagOverride{MapsTo: "x"}, "mapsTo"},
{mcptypes.CLIFlagOverride{Transform: "x"}, "transform"},
{mcptypes.CLIFlagOverride{TransformArgs: map[string]any{"x": true}}, "transformArgs"},
{mcptypes.CLIFlagOverride{RuntimeDefault: "x"}, "runtimeDefault"},
{mcptypes.CLIFlagOverride{PipelineLocal: true}, "pipelineLocal"},
{mcptypes.CLIFlagOverride{Type: "mystery"}, "type"},
{mcptypes.CLIFlagOverride{OmitWhen: "sometimes"}, "omitWhen"},
{mcptypes.CLIFlagOverride{}, ""},
}
for _, testCase := range flags {
if got := unsupportedPluginFlagOverride(testCase.value); got != testCase.want {
t.Fatalf("unsupported flag = %q, want %q", got, testCase.want)
}
}
for _, value := range []string{"", "string", "integer", "float64", "boolean", "stringSlice", "array", "json", "object"} {
if !supportedPluginFlagType(value) {
t.Fatalf("supported plugin flag type %q was rejected", value)
}
}
for _, value := range []string{"", "empty", "zero", "never"} {
if !supportedPluginOmitMode(value) {
t.Fatalf("supported plugin omit mode %q was rejected", value)
}
}
}
func TestUnsupportedPluginDescriptorRejectsEveryInvalidLayer(t *testing.T) {
testCases := []struct {
name string
mutate func(*mcptypes.ServerDescriptor)
want string
}{
{name: "overlay", mutate: func(value *mcptypes.ServerDescriptor) { value.CLI.Parent = "root" }, want: "parent"},
{name: "no tools", mutate: func(value *mcptypes.ServerDescriptor) { value.CLI.ToolOverrides = nil }, want: ""},
{name: "root", mutate: func(value *mcptypes.ServerDescriptor) { value.CLI.Command = "Bad" }, want: "command"},
{name: "declared group", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.Groups = map[string]mcptypes.CLIGroupDef{"bad_name": {}}
}, want: "groups"},
{name: "blank tool", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"": {}}
}, want: "tool"},
{name: "tool semantics", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {ServerOverride: "drive"}}
}, want: "serverOverride"},
{name: "hidden tool", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {Hidden: true, ServerOverride: "drive"}}
}, want: ""},
{name: "derived leaf", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"conference_derived_tool": {}}
}, want: ""},
{name: "leaf", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {CLIName: "Bad"}}
}, want: "cliName"},
{name: "leaf group", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {CLIName: "leaf", Group: "bad_name"}}
}, want: "group"},
{name: "flags", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {
CLIName: "leaf",
Flags: map[string]mcptypes.CLIFlagOverride{"value": {Alias: "yes"}},
}}
}, want: "flags"},
{name: "constraints", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {
CLIName: "leaf",
Flags: map[string]mcptypes.CLIFlagOverride{"value": {}},
RequireTogether: [][]string{{"value", "missing"}},
}}
}, want: "constraints"},
{name: "valid", want: ""},
}
root := pluginTestRoot()
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
descriptor := conferencePluginDescriptor()
if testCase.mutate != nil {
testCase.mutate(&descriptor)
}
if got := unsupportedPluginDescriptor(root, descriptor); got != testCase.want {
t.Fatalf("unsupported descriptor = %q, want %q", got, testCase.want)
}
})
}
}
+809
View File
@@ -0,0 +1,809 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
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/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
type pluginCaptureRunner struct {
invocations []executor.Invocation
}
func (r *pluginCaptureRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.invocations = append(r.invocations, invocation)
return executor.Result{Invocation: invocation}, nil
}
func conferencePluginDescriptor() mcptypes.ServerDescriptor {
return mcptypes.ServerDescriptor{
Key: "conference-local",
DisplayName: "conference/conference-local",
Description: "conference plugin",
Endpoint: "stdio://conference/conference-local",
Source: "plugin",
HasCLIMeta: true,
CLI: mcptypes.CLIOverlay{
ID: "conference-local",
Command: "conference",
Description: "视频会议:发起/邀请入会/会中控制",
Prefixes: []string{"conference"},
Groups: map[string]mcptypes.CLIGroupDef{
"camera": {Description: "摄像头控制"},
"mic": {Description: "麦克风控制"},
"share": {Description: "屏幕共享"},
},
ToolOverrides: map[string]mcptypes.CLIToolOverride{
"create_conference": {
CLIName: "start",
Description: "发起即时会议",
Flags: map[string]mcptypes.CLIFlagOverride{
"title": {Description: "会议标题"},
},
},
"get_conference_status": {
CLIName: "status",
Description: "查询当前会议状态",
},
"ai_end_meeting_for_all": {
CLIName: "end",
Description: "结束会议(所有人)",
IsSensitive: true,
},
"ai_open_camera": {
CLIName: "open",
Group: "camera",
Description: "打开摄像头",
},
"ai_mute_mic": {
CLIName: "mute",
Group: "mic",
Description: "静音自己",
},
"ai_share_desktop": {
CLIName: "start",
Group: "share",
Description: "开始共享桌面",
Flags: map[string]mcptypes.CLIFlagOverride{
"capture_speaker": {Description: "是否共享电脑音频"},
},
},
},
},
}
}
func pluginTestRoot(commands ...*cobra.Command) *cobra.Command {
root := &cobra.Command{
Use: "dws",
SilenceErrors: true,
SilenceUsage: true,
}
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().StringP("format", "f", "json", "")
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.AddCommand(commands...)
return root
}
func requirePluginChild(t *testing.T, parent *cobra.Command, names ...string) *cobra.Command {
t.Helper()
current := parent
for _, name := range names {
var next *cobra.Command
for _, child := range current.Commands() {
if child.Name() == name {
next = child
break
}
}
if next == nil {
t.Fatalf("missing plugin command %q below %q", name, current.CommandPath())
}
current = next
}
return current
}
func TestPluginOverlayBuildsConferenceTreeAndDispatchesOriginalProperties(t *testing.T) {
runner := &pluginCaptureRunner{}
commands := buildPluginCommands([]mcptypes.ServerDescriptor{conferencePluginDescriptor()}, runner, nil)
if len(commands) != 1 {
t.Fatalf("plugin roots = %d, want 1", len(commands))
}
conference := commands[0]
if conference.Name() != "conference" || conference.Short != "视频会议:发起/邀请入会/会中控制" {
t.Fatalf("conference root = %q / %q", conference.Name(), conference.Short)
}
if !cmdutil.IsPluginSourced(conference) {
t.Fatal("conference root is missing plugin provenance")
}
if got := requirePluginChild(t, conference, "camera").Short; got != "摄像头控制" {
t.Fatalf("camera group short = %q", got)
}
if got := requirePluginChild(t, conference, "camera", "open").Short; got != "打开摄像头" {
t.Fatalf("camera open short = %q", got)
}
requirePluginChild(t, conference, "mic", "mute")
requirePluginChild(t, conference, "status")
share := requirePluginChild(t, conference, "share", "start")
flag := share.Flags().Lookup("capture-speaker")
if flag == nil || flag.Usage != "是否共享电脑音频" {
t.Fatalf("capture-speaker flag = %#v", flag)
}
root := pluginTestRoot(commands...)
root.SetArgs([]string{
"conference", "start",
"--json", `{"from_json":"kept","title":"json"}`,
"--params", `{"from_params":2,"title":"params"}`,
"--title", "验证会议",
"--dry-run",
})
if err := root.Execute(); err != nil {
t.Fatalf("conference start: %v", err)
}
if len(runner.invocations) != 1 {
t.Fatalf("runner calls = %d, want 1", len(runner.invocations))
}
invocation := runner.invocations[0]
if invocation.Kind != "compat_invocation" ||
invocation.CanonicalProduct != "conference-local" ||
invocation.Tool != "create_conference" ||
!invocation.DryRun {
t.Fatalf("conference invocation = %#v", invocation)
}
wantParams := map[string]any{
"from_json": "kept",
"from_params": float64(2),
"title": "验证会议",
}
if !reflect.DeepEqual(invocation.Params, wantParams) {
t.Fatalf("conference params = %#v, want %#v", invocation.Params, wantParams)
}
precedenceRunner := &pluginCaptureRunner{}
precedenceRoot := pluginTestRoot(buildPluginCommands(
[]mcptypes.ServerDescriptor{conferencePluginDescriptor()},
precedenceRunner,
nil,
)...)
precedenceRoot.SetArgs([]string{
"conference", "start",
"--json", `{"title":"json"}`,
"--params", `{"title":"params"}`,
"--dry-run",
})
if err := precedenceRoot.Execute(); err != nil {
t.Fatalf("conference payload precedence: %v", err)
}
if got := precedenceRunner.invocations[0].Params["title"]; got != "params" {
t.Fatalf("conference payload title = %#v, want --params value", got)
}
}
func TestPluginOverlayTypedFlags(t *testing.T) {
descriptor := conferencePluginDescriptor()
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"typed_tool": {
CLIName: "typed",
Flags: map[string]mcptypes.CLIFlagOverride{
"conversationId": {Required: true, Description: "conversation"},
"enabled": {Type: "bool"},
"limit": {Type: "int"},
"tags": {Type: "stringSlice"},
},
},
}
runner := &pluginCaptureRunner{}
root := pluginTestRoot(buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, runner, nil)...)
root.SetArgs([]string{
"conference", "typed",
"--conversation-id", "cid",
"--enabled=false",
"--limit", "3",
"--tags", "one,two",
"--dry-run",
})
if err := root.Execute(); err != nil {
t.Fatalf("typed plugin command: %v", err)
}
if len(runner.invocations) != 1 {
t.Fatalf("runner calls = %d", len(runner.invocations))
}
invocation := runner.invocations[0]
if invocation.CanonicalProduct != "conference-local" {
t.Fatalf("canonical product = %q", invocation.CanonicalProduct)
}
want := map[string]any{
"conversationId": "cid",
"enabled": false,
"limit": 3,
"tags": []string{"one", "two"},
}
if !reflect.DeepEqual(invocation.Params, want) {
t.Fatalf("typed params = %#v, want %#v", invocation.Params, want)
}
}
func TestPluginSensitiveCommandRequiresConfirmation(t *testing.T) {
for _, testCase := range []struct {
name string
args []string
wantCalls int
wantDry bool
wantError bool
}{
{name: "blocked", args: []string{"conference", "end"}, wantError: true},
{name: "preview", args: []string{"conference", "end", "--dry-run"}, wantCalls: 1, wantDry: true},
{name: "confirmed", args: []string{"conference", "end", "--yes"}, wantCalls: 1},
} {
t.Run(testCase.name, func(t *testing.T) {
runner := &pluginCaptureRunner{}
root := pluginTestRoot(buildPluginCommands(
[]mcptypes.ServerDescriptor{conferencePluginDescriptor()}, runner, nil)...)
root.SetArgs(testCase.args)
err := root.Execute()
if testCase.wantError {
var appErr *apperrors.Error
if !errors.As(err, &appErr) ||
appErr.Category != apperrors.CategoryValidation ||
appErr.Reason != "confirmation_required" {
t.Fatalf("sensitive error = %#v", err)
}
} else if err != nil {
t.Fatalf("sensitive command: %v", err)
}
if len(runner.invocations) != testCase.wantCalls {
t.Fatalf("runner calls = %d, want %d", len(runner.invocations), testCase.wantCalls)
}
if testCase.wantCalls == 1 && runner.invocations[0].DryRun != testCase.wantDry {
t.Fatalf("dry-run = %v, want %v", runner.invocations[0].DryRun, testCase.wantDry)
}
})
}
}
func TestPluginOverlayMergesServersWithoutProbingHTTP(t *testing.T) {
isolatePluginRuntime(t)
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
calls.Add(1)
}))
defer server.Close()
first := conferencePluginDescriptor()
first.Endpoint = server.URL
first.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"one": {CLIName: "one"},
}
second := first
second.Key = "conference-extra"
second.DisplayName = "conference/conference-extra"
second.Endpoint = server.URL + "/extra"
second.CLI.ID = "conference-extra"
second.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"two": {CLIName: "two"},
}
registerPluginHTTPServer(first)
registerPluginHTTPServer(second)
runner := &pluginCaptureRunner{}
commands := buildPluginCommands([]mcptypes.ServerDescriptor{second, first}, runner, nil)
if len(commands) != 1 {
t.Fatalf("merged roots = %d, want 1", len(commands))
}
requirePluginChild(t, commands[0], "one")
requirePluginChild(t, commands[0], "two")
root := pluginTestRoot(commands...)
root.SetArgs([]string{"conference", "--help"})
if err := root.Execute(); err != nil {
t.Fatalf("conference help: %v", err)
}
if got := calls.Load(); got != 0 {
t.Fatalf("HTTP calls while building help = %d, want 0", got)
}
for _, command := range []string{"one", "two"} {
root.SetArgs([]string{"conference", command, "--dry-run"})
if err := root.Execute(); err != nil {
t.Fatalf("conference %s: %v", command, err)
}
}
if len(runner.invocations) != 2 ||
runner.invocations[0].CanonicalProduct != "conference-local" ||
runner.invocations[1].CanonicalProduct != "conference-extra" {
t.Fatalf("merged routes = %#v", runner.invocations)
}
}
func TestPluginCanReplaceHiddenFallbackButNotVisibleDistributionCommand(t *testing.T) {
root := &cobra.Command{Use: "dws"}
fallback := &cobra.Command{Use: "conference", Hidden: true}
fallback.AddCommand(&cobra.Command{Use: "meeting"})
distribution := &cobra.Command{Use: "drive"}
root.AddCommand(fallback, distribution)
conference := buildPluginCommands(
[]mcptypes.ServerDescriptor{conferencePluginDescriptor()},
executor.EchoRunner{},
nil,
)[0]
drive := &cobra.Command{Use: "drive"}
cmdutil.MarkPluginSource(drive)
addPluginCommandsSafe(root, []*cobra.Command{conference, drive})
gotConference := requirePluginChild(t, root, "conference")
if gotConference == fallback || gotConference.Hidden {
t.Fatalf("conference fallback was not replaced: %#v", gotConference)
}
requirePluginChild(t, gotConference, "status")
if gotDrive := requirePluginChild(t, root, "drive"); gotDrive != distribution {
t.Fatal("visible distribution command was replaced by a plugin")
}
}
func TestConflictingPluginDescriptorCannotReplaceDistributionEndpoint(t *testing.T) {
isolatePluginRuntime(t)
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
pluginDir := filepath.Join(configDir, "plugins", "user", "drive-hijack")
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
manifest := `{
"name":"drive-hijack",
"version":"1.0.0",
"mcpServers":{
"drive":{
"type":"streamable-http",
"endpoint":"https://plugin.invalid/mcp",
"cli":{
"id":"drive-service",
"command":"drive-hijack",
"toolOverrides":{"plugin_tool":{"cliName":"plugin-tool"}}
}
}
}
}`
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0o600); err != nil {
t.Fatal(err)
}
AppendDynamicServer(mcptypes.ServerDescriptor{
Key: "drive",
Endpoint: "https://distribution.invalid/mcp",
CLI: mcptypes.CLIOverlay{ID: "drive-service", Command: "drive"},
})
root := &cobra.Command{Use: "dws"}
root.AddCommand(&cobra.Command{Use: "drive"})
if commands := loadPlugins(root, nil, executor.EchoRunner{}); len(commands) != 0 {
t.Fatalf("conflicting plugin commands = %#v", commands)
}
if endpoint, ok := directRuntimeEndpoint("drive-service", "plugin_tool"); !ok ||
endpoint != "https://distribution.invalid/mcp" {
t.Fatalf("drive endpoint after rejected plugin = (%q, %v)", endpoint, ok)
}
}
func TestSchemaSourceRootDoesNotLoadRuntimePlugins(t *testing.T) {
isolatePluginRuntime(t)
previous := rootLoadPlugins
t.Cleanup(func() { rootLoadPlugins = previous })
var calls atomic.Int32
rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
calls.Add(1)
AppendDynamicServer(conferencePluginDescriptor())
return buildPluginCommands(
[]mcptypes.ServerDescriptor{conferencePluginDescriptor()},
executor.EchoRunner{},
nil,
)
}
base := NewSchemaSourceRootCommand()
if calls.Load() != 0 {
t.Fatalf("Schema source root loaded plugins %d times", calls.Load())
}
baseConference := requirePluginChild(t, base, "conference")
if !baseConference.Hidden || requireOptionalPluginChild(baseConference, "status") != nil {
t.Fatal("Schema source root contains installed conference plugin commands")
}
runtime := NewRootCommand()
if calls.Load() != 1 {
t.Fatalf("runtime root plugin loads = %d, want 1", calls.Load())
}
runtimeConference := requirePluginChild(t, runtime, "conference")
if runtimeConference.Hidden {
t.Fatal("runtime conference plugin is hidden")
}
requirePluginChild(t, runtimeConference, "status")
}
func requireOptionalPluginChild(parent *cobra.Command, name string) *cobra.Command {
for _, child := range parent.Commands() {
if child.Name() == name {
return child
}
}
return nil
}
func TestPluginDerivedNamesAndReservedAliases(t *testing.T) {
descriptor := conferencePluginDescriptor()
descriptor.CLI.Aliases = []string{"auth", "conf", "conf"}
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"conference_getCurrentStatus": {},
}
commands := buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, executor.EchoRunner{}, nil)
if len(commands) != 1 || !reflect.DeepEqual(commands[0].Aliases, []string{"conf"}) {
t.Fatalf("plugin aliases = %#v", commands)
}
if requireOptionalPluginChild(commands[0], "get-current-status") == nil {
var names []string
for _, command := range commands[0].Commands() {
names = append(names, command.Name())
}
t.Fatalf("derived command missing, got %s", strings.Join(names, ", "))
}
}
func TestPluginFlagsCannotShadowHostControls(t *testing.T) {
host := pluginTestRoot()
host.PersistentFlags().StringP("host-extra", "x", "", "")
reservations := pluginReservedFlags(host)
for name := range reservations.names {
t.Run(name, func(t *testing.T) {
descriptor := conferencePluginDescriptor()
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {
CLIName: "unsafe",
IsSensitive: true,
Flags: map[string]mcptypes.CLIFlagOverride{
"value": {Alias: name},
},
},
}
if commands := buildPluginCommands(
[]mcptypes.ServerDescriptor{descriptor},
executor.EchoRunner{},
host,
); len(commands) != 0 {
t.Fatalf("reserved host flag %q produced commands %#v", name, commands)
}
})
}
}
func TestPluginShorthandsCannotShadowHostOrHelp(t *testing.T) {
host := pluginTestRoot()
host.PersistentFlags().StringP("host-extra", "x", "", "")
descriptor := conferencePluginDescriptor()
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"safe": {
CLIName: "safe",
Flags: map[string]mcptypes.CLIFlagOverride{
"alpha": {Shorthand: "f"},
"bravo": {Shorthand: "h"},
"charlie": {Shorthand: "o"},
"delta": {Shorthand: "v"},
"echo": {Shorthand: "x"},
"foxtrot": {Shorthand: "y"},
},
},
}
runner := &pluginCaptureRunner{}
commands := buildPluginCommands(
[]mcptypes.ServerDescriptor{descriptor},
runner,
host,
)
if len(commands) != 1 {
t.Fatalf("plugin commands = %#v", commands)
}
host.AddCommand(commands...)
leaf := requirePluginChild(t, commands[0], "safe")
for _, name := range []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot"} {
if shorthand := leaf.Flags().Lookup(name).Shorthand; shorthand != "" {
t.Fatalf("--%s shorthand = %q, want empty", name, shorthand)
}
}
host.SetArgs([]string{"conference", "safe", "-h"})
if err := host.Execute(); err != nil {
t.Fatalf("plugin help: %v", err)
}
if len(runner.invocations) != 0 {
t.Fatalf("help executed plugin: %#v", runner.invocations)
}
}
func TestPluginPayloadPrecedenceRequiredAndTypedPositionals(t *testing.T) {
descriptor := conferencePluginDescriptor()
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"payload": {
CLIName: "payload",
Flags: map[string]mcptypes.CLIFlagOverride{
"title": {Required: true},
"mode": {Default: "fallback"},
"enabled": {Positional: true, PositionalIndex: 0, Alias: "enabled-value", Required: true, Type: "bool"},
},
},
}
for _, testCase := range []struct {
name string
args []string
wantEnabled bool
}{
{
name: "flag satisfies dual positional",
args: []string{
"conference", "payload",
"--params", `{"title":"from-json","mode":"from-json"}`,
"--enabled-value=true",
"--dry-run",
},
wantEnabled: true,
},
{
name: "json beats positional",
args: []string{
"conference", "payload", "true",
"--params", `{"title":"from-json","mode":"from-json","enabled":false}`,
"--dry-run",
},
wantEnabled: false,
},
} {
t.Run(testCase.name, func(t *testing.T) {
runner := &pluginCaptureRunner{}
root := pluginTestRoot(buildPluginCommands(
[]mcptypes.ServerDescriptor{descriptor},
runner,
nil,
)...)
root.SetArgs(testCase.args)
if err := root.Execute(); err != nil {
t.Fatalf("payload command: %v", err)
}
if len(runner.invocations) != 1 {
t.Fatalf("runner calls = %d", len(runner.invocations))
}
params := runner.invocations[0].Params
if params["title"] != "from-json" ||
params["mode"] != "from-json" ||
params["enabled"] != testCase.wantEnabled {
t.Fatalf("payload params = %#v", params)
}
})
}
}
func TestPluginDescriptorWinnerKeepsRouteAuthAndClientAtomic(t *testing.T) {
isolatePluginRuntime(t)
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
writeManifest := func(name, manifest string) {
t.Helper()
directory := filepath.Join(configDir, "plugins", "user", name)
if err := os.MkdirAll(directory, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(directory, "plugin.json"), []byte(manifest), 0o600); err != nil {
t.Fatal(err)
}
}
writeManifest("alpha-plugin", `{
"name":"alpha-plugin",
"version":"1.0.0",
"mcpServers":{
"alpha":{
"type":"streamable-http",
"endpoint":"https://alpha.invalid/mcp",
"headers":{"Authorization":"Bearer alpha-secret"},
"cli":{
"id":"shared-plugin-id",
"command":"alpha-command",
"toolOverrides":{"alpha_tool":{"cliName":"alpha"}}
}
},
"alpha-extra":{
"type":"streamable-http",
"endpoint":"https://alpha-extra.invalid/mcp",
"cli":{
"id":"alpha-extra-id",
"command":"alpha-command",
"toolOverrides":{"extra_tool":{"cliName":"extra"}}
}
}
}
}`)
writeManifest("beta-plugin", `{
"name":"beta-plugin",
"version":"1.0.0",
"mcpServers":{
"beta":{
"type":"stdio",
"command":"bin/beta",
"cli":{
"id":"shared-plugin-id",
"command":"beta-command",
"toolOverrides":{"beta_tool":{"cliName":"beta"}}
}
}
}
}`)
root := pluginTestRoot()
commands := loadPlugins(root, nil, executor.EchoRunner{})
if len(commands) != 1 || commands[0].Name() != "alpha-command" {
t.Fatalf("plugin winner commands = %#v", commands)
}
requirePluginChild(t, commands[0], "alpha")
requirePluginChild(t, commands[0], "extra")
endpoint, ok := directRuntimeEndpoint("shared-plugin-id", "alpha_tool")
if !ok || endpoint != "https://alpha.invalid/mcp" {
t.Fatalf("winner endpoint = (%q, %v)", endpoint, ok)
}
extraEndpoint, ok := directRuntimeEndpoint("alpha-extra-id", "extra_tool")
if !ok || extraEndpoint != "https://alpha-extra.invalid/mcp" {
t.Fatalf("merged server endpoint = (%q, %v)", extraEndpoint, ok)
}
auth, ok := LookupPluginAuth("shared-plugin-id")
if !ok || auth.Token != "alpha-secret" {
t.Fatalf("winner auth = (%#v, %v)", auth, ok)
}
if _, ok := LookupStdioClient("beta-plugin/beta"); ok {
t.Fatal("losing stdio client was registered")
}
}
func TestUnsupportedPluginOverlaySemanticsFailClosed(t *testing.T) {
for _, mutate := range []func(*mcptypes.ServerDescriptor){
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.RedirectTo = "drive"
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {
CLIName: "unsafe",
Flags: map[string]mcptypes.CLIFlagOverride{
"source": {MapsTo: "target"},
},
},
}
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {
CLIName: "unsafe",
Pipeline: []json.RawMessage{json.RawMessage(`{"tool":"one"}`)},
},
}
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {CLIName: "unsafe", ServerOverride: "drive"},
}
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {
CLIName: "unsafe",
Flags: map[string]mcptypes.CLIFlagOverride{
"Body.query": {},
},
},
}
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {CLIName: "unsafe", Group: "safe.bad_name"},
}
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {
CLIName: "unsafe",
Flags: map[string]mcptypes.CLIFlagOverride{"value": {}},
RequireTogether: [][]string{{"value", "missing"}},
},
}
},
} {
descriptor := conferencePluginDescriptor()
mutate(&descriptor)
if commands := buildPluginCommands(
[]mcptypes.ServerDescriptor{descriptor},
executor.EchoRunner{},
nil,
); len(commands) != 0 {
t.Fatalf("unsupported overlay produced commands %#v", commands)
}
}
}
func TestUnsupportedPluginDescriptorsDoNotRegisterRuntimeState(t *testing.T) {
isolatePluginRuntime(t)
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
writeManifest := func(name, manifest string) {
t.Helper()
directory := filepath.Join(configDir, "plugins", "user", name)
if err := os.MkdirAll(directory, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(directory, "plugin.json"), []byte(manifest), 0o600); err != nil {
t.Fatal(err)
}
}
writeManifest("unsafe-http", `{
"name":"unsafe-http",
"version":"1.0.0",
"mcpServers":{"unsafe":{
"type":"streamable-http",
"endpoint":"https://unsafe.invalid/mcp",
"headers":{"Authorization":"Bearer unsafe-secret"},
"cli":{"id":"unsafe-http-id","command":"unsafe-http","toolOverrides":{
"unsafe_tool":{"cliName":"run","serverOverride":"drive"}
}}
}}
}`)
writeManifest("unsafe-stdio", `{
"name":"unsafe-stdio",
"version":"1.0.0",
"mcpServers":{"unsafe":{
"type":"stdio",
"command":"bin/unsafe",
"cli":{"id":"unsafe-stdio-id","command":"unsafe-stdio","toolOverrides":{
"unsafe_tool":{"cliName":"run","flags":{"value":{"mapsTo":"target"}}}
}}
}}
}`)
root := pluginTestRoot()
if commands := loadPlugins(root, nil, executor.EchoRunner{}); len(commands) != 0 {
t.Fatalf("unsupported plugin descriptors produced commands %#v", commands)
}
if endpoint, ok := directRuntimeEndpoint("unsafe-http-id", "unsafe_tool"); ok {
t.Fatalf("unsupported HTTP descriptor registered endpoint %q", endpoint)
}
if _, ok := LookupPluginAuth("unsafe-http-id"); ok {
t.Fatal("unsupported HTTP descriptor registered plugin auth")
}
if _, ok := LookupStdioClient("unsafe-stdio/unsafe"); ok {
t.Fatal("unsupported stdio descriptor registered a client")
}
}
+239
View File
@@ -0,0 +1,239 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"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/transport"
)
func pluginToolInputSchema(
tools transport.ToolsListResult,
toolName string,
) (map[string]any, bool) {
for _, tool := range tools.Tools {
if strings.TrimSpace(tool.Name) == strings.TrimSpace(toolName) {
return tool.InputSchema, true
}
}
return nil, false
}
func normalizePluginInputParams(
params map[string]any,
schema map[string]any,
) (map[string]any, error) {
schema = canonicalPluginInputSchema(schema)
normalized := make(map[string]any, len(params))
for key, value := range params {
normalized[key] = value
}
if _, err := coercePluginSchemaValue(normalized, schema); err != nil {
return nil, cliInputValidationError(err)
}
if err := cli.ValidateInputSchema(normalized, schema); err != nil {
return nil, err
}
return normalized, nil
}
func canonicalPluginInputSchema(schema map[string]any) map[string]any {
if len(schema) == 0 {
return schema
}
cloned := make(map[string]any, len(schema))
for key, value := range schema {
cloned[key] = clonePluginSchemaValue(key, value)
}
return cloned
}
func clonePluginSchemaValue(key string, value any) any {
switch typed := value.(type) {
case map[string]any:
cloned := make(map[string]any, len(typed))
for childKey, childValue := range typed {
cloned[childKey] = clonePluginSchemaValue(childKey, childValue)
}
return cloned
case []any:
cloned := make([]any, len(typed))
for index, item := range typed {
cloned[index] = clonePluginSchemaValue(key, item)
}
return cloned
case []string:
cloned := make([]string, len(typed))
for index, item := range typed {
if key == "type" {
item = canonicalPluginSchemaType(item)
}
cloned[index] = item
}
return cloned
case string:
if key == "type" {
return canonicalPluginSchemaType(typed)
}
return typed
default:
return value
}
}
func canonicalPluginSchemaType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "bool":
return "boolean"
case "int":
return "integer"
case "float":
return "number"
default:
return value
}
}
func cliInputValidationError(err error) error {
if err == nil {
return nil
}
return apperrors.NewValidation(
fmt.Sprintf("input schema normalization failed: %v", err),
apperrors.WithReason("plugin_input_schema_invalid"),
)
}
func coercePluginSchemaValue(value any, schema map[string]any) (any, error) {
target := singlePluginSchemaType(schema)
if raw, ok := value.(string); ok {
trimmed := strings.TrimSpace(raw)
switch target {
case "bool", "boolean":
parsed, err := strconv.ParseBool(trimmed)
if err != nil {
return nil, fmt.Errorf("cannot convert %q to boolean: %w", raw, err)
}
value = parsed
case "int", "integer":
parsed, err := strconv.Atoi(trimmed)
if err != nil {
return nil, fmt.Errorf("cannot convert %q to integer: %w", raw, err)
}
value = parsed
case "float", "number":
parsed, err := strconv.ParseFloat(trimmed, 64)
if err != nil {
return nil, fmt.Errorf("cannot convert %q to number: %w", raw, err)
}
value = parsed
case "object":
var parsed map[string]any
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
return nil, fmt.Errorf("cannot convert plugin parameter to object: %w", err)
}
if parsed == nil {
return nil, fmt.Errorf("cannot convert plugin parameter to object: expected a JSON object")
}
value = parsed
case "array":
var parsed []any
if strings.HasPrefix(trimmed, "[") {
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
return nil, fmt.Errorf("cannot convert plugin parameter to array: %w", err)
}
} else if trimmed != "" {
for _, item := range strings.Split(trimmed, ",") {
if item = strings.TrimSpace(item); item != "" {
parsed = append(parsed, item)
}
}
}
value = parsed
}
}
switch typed := value.(type) {
case map[string]any:
properties, _ := schema["properties"].(map[string]any)
for key, propertyValue := range typed {
propertySchema, _ := properties[key].(map[string]any)
if len(propertySchema) == 0 {
continue
}
coerced, err := coercePluginSchemaValue(propertyValue, propertySchema)
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
typed[key] = coerced
}
return typed, nil
case []string:
items := make([]any, len(typed))
for index, item := range typed {
items[index] = item
}
value = items
}
if items, ok := value.([]any); ok {
itemSchema, _ := schema["items"].(map[string]any)
if len(itemSchema) == 0 {
return items, nil
}
for index, item := range items {
coerced, err := coercePluginSchemaValue(item, itemSchema)
if err != nil {
return nil, fmt.Errorf("item %d: %w", index, err)
}
items[index] = coerced
}
return items, nil
}
return value, nil
}
func singlePluginSchemaType(schema map[string]any) string {
var types []string
switch typed := schema["type"].(type) {
case string:
types = []string{typed}
case []string:
types = typed
case []any:
for _, value := range typed {
if text, ok := value.(string); ok {
types = append(types, text)
}
}
}
var target string
for _, candidate := range types {
candidate = strings.TrimSpace(candidate)
if candidate == "" || candidate == "null" {
continue
}
if target != "" && target != candidate {
return ""
}
target = candidate
}
return target
}
@@ -0,0 +1,229 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"errors"
"reflect"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func TestPluginToolInputSchemaMatchesTrimmedName(t *testing.T) {
want := map[string]any{"type": "object"}
tools := transport.ToolsListResult{Tools: []transport.ToolDescriptor{
{Name: "other", InputSchema: map[string]any{"type": "string"}},
{Name: " create_conference ", InputSchema: want},
}}
got, ok := pluginToolInputSchema(tools, " create_conference ")
if !ok || !reflect.DeepEqual(got, want) {
t.Fatalf("pluginToolInputSchema() = (%#v, %v), want (%#v, true)", got, ok, want)
}
if got, ok := pluginToolInputSchema(tools, "missing"); ok || got != nil {
t.Fatalf("missing pluginToolInputSchema() = (%#v, %v), want (nil, false)", got, ok)
}
}
func TestNormalizePluginInputParamsCoercesNestedValues(t *testing.T) {
schema := map[string]any{
"type": "object",
"required": []string{"enabled"},
"properties": map[string]any{
"enabled": map[string]any{"type": []any{"null", "bool"}},
"count": map[string]any{"type": "int"},
"ratio": map[string]any{"type": "float"},
"settings": map[string]any{
"type": "object",
"properties": map[string]any{
"active": map[string]any{"type": "bool"},
},
},
"ids": map[string]any{
"type": []string{"array", "null"},
"items": map[string]any{"type": "int"},
},
"labels": map[string]any{
"type": "array",
"items": map[string]any{"type": "string"},
},
"booleans": map[string]any{
"type": "array",
"items": map[string]any{"type": "bool"},
},
"ambiguous": map[string]any{"type": []string{"string", "int"}},
},
}
params := map[string]any{
"enabled": " true ",
"count": " 7 ",
"ratio": " 2.5 ",
"settings": `{"active":"false"}`,
"ids": `["1", "2"]`,
"labels": "alpha, , beta",
"booleans": []string{"true", "false"},
"ambiguous": "9",
}
got, err := normalizePluginInputParams(params, schema)
if err != nil {
t.Fatalf("normalizePluginInputParams() error = %v", err)
}
want := map[string]any{
"enabled": true,
"count": 7,
"ratio": 2.5,
"settings": map[string]any{"active": false},
"ids": []any{1, 2},
"labels": []any{"alpha", "beta"},
"booleans": []any{true, false},
"ambiguous": "9",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("normalizePluginInputParams() = %#v, want %#v", got, want)
}
properties := schema["properties"].(map[string]any)
if gotType := properties["enabled"].(map[string]any)["type"].([]any)[1]; gotType != "bool" {
t.Fatalf("normalization mutated source schema type to %#v", gotType)
}
if gotValue := params["enabled"]; gotValue != " true " {
t.Fatalf("normalization mutated source params to %#v", gotValue)
}
}
func TestNormalizePluginInputParamsReportsConversionPath(t *testing.T) {
tests := []struct {
name string
value any
fieldSchema map[string]any
wantText string
}{
{name: "boolean", value: "sometimes", fieldSchema: map[string]any{"type": "bool"}, wantText: "cannot convert"},
{name: "integer", value: "1.5", fieldSchema: map[string]any{"type": "int"}, wantText: "integer"},
{name: "number", value: "many", fieldSchema: map[string]any{"type": "float"}, wantText: "number"},
{name: "object", value: "{", fieldSchema: map[string]any{"type": "object"}, wantText: "object"},
{name: "null object", value: "null", fieldSchema: map[string]any{"type": "object"}, wantText: "expected a JSON object"},
{name: "array", value: "[", fieldSchema: map[string]any{"type": "array"}, wantText: "array"},
{
name: "nested property",
value: `{"active":"sometimes"}`,
fieldSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"active": map[string]any{"type": "bool"},
},
},
wantText: "field: active:",
},
{
name: "array item",
value: "1,not-an-int",
fieldSchema: map[string]any{
"type": "array",
"items": map[string]any{"type": "int"},
},
wantText: "item 1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
schema := map[string]any{
"type": "object",
"properties": map[string]any{"field": tt.fieldSchema},
}
_, err := normalizePluginInputParams(map[string]any{"field": tt.value}, schema)
if err == nil {
t.Fatal("normalizePluginInputParams() error = nil, want conversion error")
}
var appError *apperrors.Error
if !errors.As(err, &appError) ||
appError.Category != apperrors.CategoryValidation ||
appError.Reason != "plugin_input_schema_invalid" {
t.Fatalf("conversion error = %#v, want categorized plugin schema validation error", err)
}
if !strings.Contains(err.Error(), tt.wantText) {
t.Fatalf("conversion error = %q, want text %q", err, tt.wantText)
}
})
}
}
func TestNormalizePluginInputParamsRunsSchemaValidation(t *testing.T) {
schema := map[string]any{
"type": "object",
"required": []any{"name"},
"properties": map[string]any{
"name": map[string]any{"type": "string"},
},
}
if _, err := normalizePluginInputParams(map[string]any{}, schema); err == nil ||
!strings.Contains(err.Error(), "$.name is required") {
t.Fatalf("required-field validation error = %v", err)
}
}
func TestPluginInputSchemaHelperEdges(t *testing.T) {
if got := canonicalPluginInputSchema(nil); got != nil {
t.Fatalf("canonicalPluginInputSchema(nil) = %#v, want nil", got)
}
if got := clonePluginSchemaValue("minimum", 1); got != 1 {
t.Fatalf("clonePluginSchemaValue(scalar) = %#v, want 1", got)
}
if got := cliInputValidationError(nil); got != nil {
t.Fatalf("cliInputValidationError(nil) = %v, want nil", got)
}
if got, err := coercePluginSchemaValue("", map[string]any{"type": "array"}); err != nil || !reflect.DeepEqual(got, []any(nil)) {
t.Fatalf("empty array coercion = (%#v, %v), want nil slice", got, err)
}
items := []any{"unchanged"}
if got, err := coercePluginSchemaValue(items, map[string]any{"type": "array"}); err != nil || !reflect.DeepEqual(got, items) {
t.Fatalf("array without item schema = (%#v, %v)", got, err)
}
if got, err := coercePluginSchemaValue(12, map[string]any{"type": "integer"}); err != nil || got != 12 {
t.Fatalf("non-string scalar coercion = (%#v, %v), want (12, nil)", got, err)
}
unknown := map[string]any{"unknown": "unchanged"}
if got, err := coercePluginSchemaValue(unknown, map[string]any{
"type": "object",
"properties": map[string]any{},
}); err != nil || !reflect.DeepEqual(got, unknown) {
t.Fatalf("unknown property coercion = (%#v, %v), want unchanged map", got, err)
}
tests := []struct {
name string
schema map[string]any
want string
}{
{name: "missing", schema: map[string]any{}, want: ""},
{name: "single string", schema: map[string]any{"type": "integer"}, want: "integer"},
{name: "single string slice", schema: map[string]any{"type": []string{"null", "number"}}, want: "number"},
{name: "any slice", schema: map[string]any{"type": []any{nil, 3, "", "null", "boolean"}}, want: "boolean"},
{name: "ambiguous", schema: map[string]any{"type": []any{"string", "integer"}}, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := singlePluginSchemaType(tt.schema); got != tt.want {
t.Fatalf("singlePluginSchemaType(%#v) = %q, want %q", tt.schema, got, tt.want)
}
})
}
for raw, want := range map[string]string{
" BOOL ": "boolean",
"Int": "integer",
"FLOAT": "number",
"custom": "custom",
} {
if got := canonicalPluginSchemaType(raw); got != want {
t.Errorf("canonicalPluginSchemaType(%q) = %q, want %q", raw, got, want)
}
}
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"errors"
"reflect"
"testing"
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/transport"
)
func TestPluginStdioExecutionNormalizesAndValidatesLiveSchema(t *testing.T) {
isolatePluginRuntime(t)
previousInit := runnerStdioEnsureInitialized
previousList := runnerStdioListTools
previousCall := runnerStdioCallTool
t.Cleanup(func() {
runnerStdioEnsureInitialized = previousInit
runnerStdioListTools = previousList
runnerStdioCallTool = previousCall
})
client := transport.NewStdioClient("unused", nil, nil)
RegisterStdioClient("conference/local", client)
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error {
return nil
}
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
return transport.ToolsListResult{
Tools: []transport.ToolDescriptor{{
Name: "create_conference",
InputSchema: map[string]any{
"type": "object",
"required": []any{"title"},
"properties": map[string]any{
"title": map[string]any{"type": "string"},
"capture_speaker": map[string]any{"type": "bool"},
},
"additionalProperties": false,
},
}},
}, nil
}
var calledParams map[string]any
runnerStdioCallTool = func(
_ *transport.StdioClient,
_ context.Context,
_ string,
params map[string]any,
) (transport.ToolCallResult, error) {
calledParams = params
return transport.ToolCallResult{Content: map[string]any{"ok": true}}, nil
}
runner := &runtimeRunner{}
invocation := executor.Invocation{
CanonicalProduct: "conference-local",
Tool: "create_conference",
Params: map[string]any{
"title": "schema validation",
"capture_speaker": "true",
},
}
result, err := runner.executeInvocation(
context.Background(),
"stdio://conference/local",
invocation,
)
if err != nil {
t.Fatalf("stdio plugin execution: %v", err)
}
wantParams := map[string]any{
"title": "schema validation",
"capture_speaker": true,
}
if !reflect.DeepEqual(calledParams, wantParams) ||
!reflect.DeepEqual(result.Invocation.Params, wantParams) {
t.Fatalf("normalized wire params = %#v, result = %#v", calledParams, result.Invocation.Params)
}
calledParams = nil
invocation.Params = map[string]any{"capture_speaker": "true"}
_, err = runner.executeInvocation(
context.Background(),
"stdio://conference/local",
invocation,
)
var appError *apperrors.Error
if !errors.As(err, &appError) ||
appError.Category != apperrors.CategoryValidation ||
calledParams != nil {
t.Fatalf("missing required schema validation = %#v, call params = %#v", err, calledParams)
}
}
@@ -0,0 +1,369 @@
// 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 (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"testing"
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/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/userdef"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
type schemaSourceContextKey struct{}
func TestSchemaSourceRootPropagatesContextWithoutLoadingPlugins(t *testing.T) {
previous := rootLoadPlugins
t.Cleanup(func() { rootLoadPlugins = previous })
pluginLoads := 0
rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
pluginLoads++
return nil
}
wantContext := context.WithValue(context.Background(), schemaSourceContextKey{}, "schema")
root := NewSchemaSourceRootCommand(wantContext)
if root.Context() != wantContext {
t.Fatal("Schema source root did not retain the caller context")
}
if pluginLoads != 0 {
t.Fatalf("Schema source root loaded runtime plugins %d times", pluginLoads)
}
}
func TestCollectPluginServerCandidatesSortsAndSkipsInvalidStdio(t *testing.T) {
previousDescriptors := rootPluginDescriptors
previousClients := rootPluginStdioClients
previousDescriptor := rootPluginStdioDescriptor
t.Cleanup(func() {
rootPluginDescriptors = previousDescriptors
rootPluginStdioClients = previousClients
rootPluginStdioDescriptor = previousDescriptor
})
first := &plugin.Plugin{Manifest: plugin.Manifest{Name: "first"}}
second := &plugin.Plugin{Manifest: plugin.Manifest{Name: "second"}}
wantContext := &plugin.UserContext{UserID: "user", CorpID: "corp"}
client := transport.NewStdioClient("unused", nil, nil)
rootPluginDescriptors = func(owner *plugin.Plugin) []mcptypes.ServerDescriptor {
if owner == first {
return []mcptypes.ServerDescriptor{{Key: "same"}, {Key: " beta "}}
}
return []mcptypes.ServerDescriptor{{Key: "aardvark"}}
}
rootPluginStdioClients = func(owner *plugin.Plugin, gotContext *plugin.UserContext) []plugin.StdioServerClient {
if gotContext != wantContext {
t.Fatalf("stdio user context = %#v, want %#v", gotContext, wantContext)
}
if owner != first {
return nil
}
return []plugin.StdioServerClient{
{Key: "same", Client: client},
{Key: " alpha ", Client: client},
{Key: "invalid", Client: client},
}
}
rootPluginStdioDescriptor = func(_ *plugin.Plugin, stdio plugin.StdioServerClient) (mcptypes.ServerDescriptor, bool) {
if stdio.Key == "invalid" {
return mcptypes.ServerDescriptor{}, false
}
return mcptypes.ServerDescriptor{Key: stdio.Key}, true
}
candidates := collectPluginServerCandidates([]*plugin.Plugin{first, second}, wantContext)
if len(candidates) != 5 {
t.Fatalf("candidate count = %d, want 5", len(candidates))
}
gotKeys := make([]string, 0, len(candidates))
gotKinds := make([]string, 0, len(candidates))
for _, candidate := range candidates {
gotKeys = append(gotKeys, candidate.descriptor.Key)
if candidate.stdioClient == nil {
gotKinds = append(gotKinds, "http")
} else {
gotKinds = append(gotKinds, "stdio")
if candidate.stdioClient.Client != client {
t.Fatal("stdio candidate did not retain its client")
}
}
}
if want := []string{" alpha ", " beta ", "same", "same", "aardvark"}; !reflect.DeepEqual(gotKeys, want) {
t.Fatalf("candidate keys = %#v, want %#v", gotKeys, want)
}
if want := []string{"stdio", "http", "http", "stdio", "http"}; !reflect.DeepEqual(gotKinds, want) {
t.Fatalf("candidate transports = %#v, want %#v", gotKinds, want)
}
}
func TestPluginDescriptorBlankIdentityAndDistributionOwnership(t *testing.T) {
isolatePluginRuntime(t)
blank := mcptypes.ServerDescriptor{
Key: " ",
CLI: mcptypes.CLIOverlay{
ID: " ",
Command: " ",
Aliases: []string{"", " "},
},
}
if claims := pluginDescriptorIdentityClaims(blank); len(claims) != 0 {
t.Fatalf("blank descriptor claims = %#v, want none", claims)
}
if rootName := pluginDescriptorRootName(blank); rootName != "" {
t.Fatalf("blank descriptor root = %q", rootName)
}
owner := &plugin.Plugin{Manifest: plugin.Manifest{Name: "blank"}}
accepted := selectPluginServerCandidates(
&cobra.Command{Use: "dws"},
[]pluginServerCandidate{
{owner: owner, descriptor: mcptypes.ServerDescriptor{CLI: mcptypes.CLIOverlay{Skip: true}}},
{owner: owner, descriptor: blank},
},
)
if len(accepted) != 1 {
t.Fatalf("blank descriptor candidates = %#v, want one accepted candidate", accepted)
}
if distributionRootOwns(nil, "visible") {
t.Fatal("nil root claimed a command")
}
root := &cobra.Command{Use: "dws"}
visible := &cobra.Command{Use: "visible", Aliases: []string{" visible-alias "}}
hiddenFallback := &cobra.Command{Use: "conference", Hidden: true}
hiddenOwned := &cobra.Command{Use: "hidden-owned", Hidden: true}
pluginOwned := &cobra.Command{Use: "plugin-owned", Aliases: []string{"plugin-alias"}}
cmdutil.MarkPluginSource(pluginOwned)
root.AddCommand(visible, hiddenFallback, hiddenOwned, pluginOwned)
for _, name := range []string{"visible", "visible-alias", "hidden-owned"} {
if !distributionRootOwns(root, name) {
t.Errorf("distribution root did not claim %q", name)
}
}
for _, name := range []string{"conference", "plugin-owned", "plugin-alias", "missing"} {
if distributionRootOwns(root, name) {
t.Errorf("distribution root unexpectedly claimed %q", name)
}
}
}
func TestReplaceableFallbackIdentitySurvivesDistributionConflictChecks(t *testing.T) {
isolatePluginRuntime(t)
SetDynamicServers([]mcptypes.ServerDescriptor{
{
Key: "conference",
Endpoint: "https://example.com/conference/mcp",
CLI: mcptypes.CLIOverlay{ID: "conference"},
},
{
Key: "chat",
Endpoint: "https://example.com/chat/mcp",
CLI: mcptypes.CLIOverlay{ID: "chat"},
},
})
root := &cobra.Command{Use: "dws"}
root.AddCommand(&cobra.Command{Use: "conference", Hidden: true})
distributionProducts := DirectRuntimeProductIDs()
conferenceDescriptor := mcptypes.ServerDescriptor{
Key: "conference-local",
DisplayName: "conference/conference-local",
CLI: mcptypes.CLIOverlay{ID: "conference-local", Command: "conference"},
}
if pluginDescriptorConflictsWithDistribution(root, conferenceDescriptor, distributionProducts) {
t.Fatal("replaceable fallback identity blocked plugin server selection")
}
chatDescriptor := mcptypes.ServerDescriptor{
Key: "chat-local",
DisplayName: "chat/chat-local",
CLI: mcptypes.CLIOverlay{ID: "chat-local", Command: "chat"},
}
if !pluginDescriptorConflictsWithDistribution(root, chatDescriptor, distributionProducts) {
t.Fatal("non-replaceable distribution product no longer conflicts")
}
reservedDescriptor := mcptypes.ServerDescriptor{
Key: "auth-local",
DisplayName: "auth/auth-local",
CLI: mcptypes.CLIOverlay{ID: "auth-local", Command: "auth"},
}
if !pluginDescriptorConflictsWithDistribution(root, reservedDescriptor, distributionProducts) {
t.Fatal("reserved command name no longer conflicts")
}
first := &plugin.Plugin{Manifest: plugin.Manifest{Name: "conference"}}
second := &plugin.Plugin{Manifest: plugin.Manifest{Name: "other"}}
accepted := selectPluginServerCandidates(root, []pluginServerCandidate{
{owner: first, descriptor: conferenceDescriptor},
{
owner: second,
descriptor: mcptypes.ServerDescriptor{
Key: "conference-other",
DisplayName: "other/conference-other",
CLI: mcptypes.CLIOverlay{ID: "conference-other", Command: "conference"},
},
},
})
if len(accepted) != 1 {
t.Fatalf("accepted candidates = %d, want the first conference plugin only", len(accepted))
}
if accepted[0].owner != first {
t.Fatalf("accepted owner = %q, want the first conference plugin", accepted[0].owner.Manifest.Name)
}
}
func TestAddPluginCommandsSafeFiltersConflictingAliases(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.AddCommand(&cobra.Command{Use: "taken"})
command := &cobra.Command{
Use: "extension",
Aliases: []string{"", "extension", "auth", "taken", "shared", " shared ", " okay "},
}
addPluginCommandsSafe(root, []*cobra.Command{
command,
{Use: "shared"},
{Use: "other", Aliases: []string{"extension"}},
})
if want := []string{"shared", "okay"}; !reflect.DeepEqual(command.Aliases, want) {
t.Fatalf("filtered aliases = %#v, want %#v", command.Aliases, want)
}
if child := findDirectChild(root, "shared"); child != nil {
t.Fatal("an accepted alias was also registered as a plugin primary command")
}
other := findDirectChild(root, "other")
if other == nil || len(other.Aliases) != 0 {
t.Fatalf("later plugin aliases = %#v", other)
}
}
func TestStdioRunnerReportsToolsListFailureAndMissingTool(t *testing.T) {
isolatePluginRuntime(t)
previousInit := runnerStdioEnsureInitialized
previousList := runnerStdioListTools
previousCall := runnerStdioCallTool
t.Cleanup(func() {
runnerStdioEnsureInitialized = previousInit
runnerStdioListTools = previousList
runnerStdioCallTool = previousCall
})
client := transport.NewStdioClient("unused", nil, nil)
RegisterStdioClient("plugin/server", client)
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return nil }
toolCalls := 0
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
toolCalls++
return transport.ToolCallResult{}, nil
}
runner := &runtimeRunner{}
invocation := executor.Invocation{CanonicalProduct: "overlay-id", Tool: "wanted"}
listFailure := errors.New("list failed")
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
return transport.ToolsListResult{}, listFailure
}
_, err := runner.executeStdioInvocationAtEndpoint(context.Background(), "stdio://plugin/server", invocation)
assertPluginRuntimeError(t, err, apperrors.CategoryAPI, "tools/list", "stdio_tools_list_error")
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
return transport.ToolsListResult{Tools: []transport.ToolDescriptor{{Name: "other"}}}, nil
}
_, err = runner.executeStdioInvocationAtEndpoint(context.Background(), "stdio://plugin/server", invocation)
assertPluginRuntimeError(t, err, apperrors.CategoryValidation, "", "plugin_tool_not_found")
if toolCalls != 0 {
t.Fatalf("tools/call attempts after tools/list failures = %d", toolCalls)
}
}
func TestStdioManifestDescriptorAndRegistrationFailClosed(t *testing.T) {
isolatePluginRuntime(t)
p := &plugin.Plugin{
Manifest: plugin.Manifest{
Name: "broken-plugin",
MCPServers: map[string]*plugin.MCPServer{
"local": {CLI: json.RawMessage(`{`)},
},
},
}
server := plugin.StdioServerClient{
Key: "local",
Client: transport.NewStdioClient("unused", nil, nil),
}
if descriptor, ok := stdioServerDescriptorFromManifest(p, server); ok || !reflect.ValueOf(descriptor).IsZero() {
t.Fatalf("invalid descriptor = (%#v, %v), want zero, false", descriptor, ok)
}
if descriptor := registerStdioServerFromManifest(p, server); !reflect.ValueOf(descriptor).IsZero() {
t.Fatalf("invalid registered descriptor = %#v, want zero", descriptor)
}
if _, ok := LookupStdioClient("broken-plugin/local"); ok {
t.Fatal("invalid stdio manifest registered a client")
}
}
func TestLegacyCommandsContinueWhenUserShortcutLoadFails(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
shortcutDir := filepath.Join(configDir, "shortcuts")
if err := os.MkdirAll(shortcutDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(shortcutDir, "broken.yaml"), []byte("version: ["), 0o600); err != nil {
t.Fatal(err)
}
if _, loadErrors := userdef.Load(); len(loadErrors) == 0 {
t.Fatal("malformed shortcut fixture did not fail to load")
}
runner := executor.EchoRunner{}
caller := newToolCallerAdapter(runner, &GlobalFlags{})
if commands := newLegacyPublicCommands(runner, caller, true); len(commands) == 0 {
t.Fatal("legacy commands were dropped after a user shortcut load error")
}
}
func findDirectChild(root *cobra.Command, name string) *cobra.Command {
for _, command := range root.Commands() {
if command.Name() == name {
return command
}
}
return nil
}
func assertPluginRuntimeError(
t *testing.T,
err error,
wantCategory apperrors.Category,
wantOperation string,
wantReason string,
) {
t.Helper()
var appError *apperrors.Error
if !errors.As(err, &appError) {
t.Fatalf("runtime error = %#v, want structured app error", err)
}
if appError.Category != wantCategory ||
appError.Operation != wantOperation ||
appError.Reason != wantReason {
t.Fatalf("runtime error = %#v, want category=%q operation=%q reason=%q", appError, wantCategory, wantOperation, wantReason)
}
}
+38 -2
View File
@@ -5,6 +5,7 @@
package app
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
@@ -12,6 +13,7 @@ import (
"sync/atomic"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
@@ -35,6 +37,11 @@ func isolatePluginRuntime(t *testing.T) {
stdioClients = make(map[string]*transport.StdioClient)
stdioMu.Unlock()
pluginAuthMu.Lock()
previousPluginAuth := pluginAuthRegistry
pluginAuthRegistry = make(map[string]*PluginAuth)
pluginAuthMu.Unlock()
t.Cleanup(func() {
StopAllStdioClients()
dynamicMu.Lock()
@@ -46,6 +53,9 @@ func isolatePluginRuntime(t *testing.T) {
stdioMu.Lock()
stdioClients = previousStdio
stdioMu.Unlock()
pluginAuthMu.Lock()
pluginAuthRegistry = previousPluginAuth
pluginAuthMu.Unlock()
})
}
@@ -77,14 +87,40 @@ func TestRegisterPluginHTTPServerDoesNotProbeEndpoint(t *testing.T) {
func TestRegisterStdioServerFromManifestDoesNotStartProcess(t *testing.T) {
isolatePluginRuntime(t)
marker := t.TempDir() + "/started"
pluginRoot := t.TempDir()
if err := os.WriteFile(pluginRoot+"/overlay.json", []byte(`{
"id":"local",
"command":"lazy-stdio",
"groups":{"health":{"description":"health checks"}},
"toolOverrides":{"ping":{"cliName":"ping","group":"health"}}
}`), 0o600); err != nil {
t.Fatal(err)
}
client := transport.NewStdioClient("/bin/sh", []string{
"-c", fmt.Sprintf("printf started > %q", marker),
}, nil)
p := &plugin.Plugin{
Manifest: plugin.Manifest{Name: "lazy-stdio", Description: "lazy stdio test"},
Root: t.TempDir(),
Manifest: plugin.Manifest{
Name: "lazy-stdio",
Description: "lazy stdio test",
MCPServers: map[string]*plugin.MCPServer{
"local": {
Type: "stdio",
Command: "unused",
CLI: json.RawMessage(`"overlay.json"`),
},
},
},
Root: pluginRoot,
}
descriptor := registerStdioServerFromManifest(p, plugin.StdioServerClient{Key: "local", Client: client})
commands := buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, executor.EchoRunner{}, nil)
root := pluginTestRoot(commands...)
root.SetArgs([]string{"lazy-stdio", "--help"})
if err := root.Execute(); err != nil {
t.Fatalf("lazy stdio help: %v", err)
}
requirePluginChild(t, commands[0], "health", "ping")
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("stdio process started during registration: stat error = %v", err)
+34 -44
View File
@@ -14,10 +14,7 @@
package app
import (
"encoding/json"
"log/slog"
"os"
"path/filepath"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
@@ -33,50 +30,26 @@ import (
// When no CLI metadata is present, a minimal overlay keyed by the server
// name is returned so callers can still build an identity descriptor.
func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.CLIOverlay {
serverID := sc.Key
overlay := mcptypes.CLIOverlay{
ID: serverID,
Command: serverID,
}
srv, ok := p.Manifest.MCPServers[sc.Key]
if !ok || len(srv.CLI) == 0 {
return overlay
}
cliData := srv.CLI
// A JSON string is interpreted as a relative path to an external
// overlay file (e.g. "overlay.json") anchored at the plugin root.
if len(cliData) > 0 && cliData[0] == '"' {
var cliPath string
if err := json.Unmarshal(cliData, &cliPath); err == nil && cliPath != "" {
absPath := filepath.Join(p.Root, cliPath)
if fileData, readErr := os.ReadFile(absPath); readErr == nil {
cliData = fileData
} else {
slog.Warn("plugin: failed to read CLI overlay file",
"plugin", p.Manifest.Name, "path", absPath, "error", readErr)
}
overlay, ok := p.ResolveCLIOverlay(sc.Key)
if !ok {
return mcptypes.CLIOverlay{
ID: sc.Key,
Command: sc.Key,
Skip: true,
}
}
if err := json.Unmarshal(cliData, &overlay); err != nil {
slog.Warn("plugin: failed to parse CLI overlay for stdio server",
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
}
if overlay.ID == "" {
overlay.ID = serverID
}
if overlay.Command == "" {
overlay.Command = serverID
}
return overlay
}
// registerStdioServerFromManifest registers an endpoint descriptor and an
// unstarted client from versioned plugin metadata. Tool discovery is not part
// of command-tree construction; execution starts and initializes the client.
func registerStdioServerFromManifest(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.ServerDescriptor {
overlay := resolveStdioOverlay(p, sc)
descriptor := mcptypes.ServerDescriptor{
func stdioServerDescriptorFromManifest(
p *plugin.Plugin,
sc plugin.StdioServerClient,
) (mcptypes.ServerDescriptor, bool) {
overlay, ok := p.ResolveCLIOverlay(sc.Key)
if !ok {
return mcptypes.ServerDescriptor{}, false
}
return mcptypes.ServerDescriptor{
Key: sc.Key,
DisplayName: p.Manifest.Name + "/" + sc.Key,
Description: p.Manifest.Description,
@@ -84,13 +57,30 @@ func registerStdioServerFromManifest(p *plugin.Plugin, sc plugin.StdioServerClie
Source: "plugin",
CLI: overlay,
HasCLIMeta: true,
}
}, true
}
func registerResolvedStdioServer(
p *plugin.Plugin,
sc plugin.StdioServerClient,
descriptor mcptypes.ServerDescriptor,
) {
AppendDynamicServer(descriptor)
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
slog.Debug("plugin: stdio server registered from manifest",
"plugin", p.Manifest.Name, "server", sc.Key,
"toolOverrides", len(overlay.ToolOverrides))
"toolOverrides", len(descriptor.CLI.ToolOverrides))
}
// registerStdioServerFromManifest registers an endpoint descriptor and an
// unstarted client from versioned plugin metadata. Tool discovery is not part
// of command-tree construction; execution starts and initializes the client.
func registerStdioServerFromManifest(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.ServerDescriptor {
descriptor, ok := stdioServerDescriptorFromManifest(p, sc)
if !ok {
return mcptypes.ServerDescriptor{}
}
registerResolvedStdioServer(p, sc, descriptor)
return descriptor
}
+52 -11
View File
@@ -266,7 +266,7 @@ func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, e
}
choice := strings.TrimSpace(cfg.CurrentProfile)
if choice == "" {
choice = authpkg.ProfileSelector(cfg.Profiles[0])
choice = authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg)
}
return profileSwitchTUIRunner(cmd, cfg, choice)
}
@@ -428,11 +428,36 @@ func (m profileSwitchTUIModel) selectedCorpID() string {
if m.selected < 0 || m.selected >= len(m.profiles) {
return ""
}
return authpkg.ProfileSelector(m.profiles[m.selected])
selected := m.profiles[m.selected]
return authpkg.ProfileSelectionSelector(selected, &authpkg.ProfilesConfig{Profiles: m.profiles})
}
func profileSwitchProfileIndex(profiles []authpkg.Profile, selector string, cfg *authpkg.ProfilesConfig) int {
selector = strings.TrimSpace(selector)
for i, profile := range profiles {
if authpkg.ProfileSelectionSelector(profile, cfg) == selector {
return i
}
}
// Accept an old current/previous pointer long enough for the migration path
// to canonicalize it. Only an unresolved profile in a multi-account
// organization qualifies, so ordinary exact account names cannot capture an
// identity selector that contains ':'.
legacyBlank := -1
for i, profile := range profiles {
if strings.TrimSpace(profile.UserID) != "" || strings.TrimSpace(profile.Name) != selector ||
profileCountForCorp(cfg, profile.CorpID) <= 1 {
continue
}
if legacyBlank >= 0 {
legacyBlank = -1
break
}
legacyBlank = i
}
if legacyBlank >= 0 {
return legacyBlank
}
if corpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact {
for i, p := range profiles {
if strings.TrimSpace(p.CorpID) == corpID && strings.TrimSpace(p.UserID) == userID {
@@ -443,6 +468,9 @@ func profileSwitchProfileIndex(profiles []authpkg.Profile, selector string, cfg
}
fallback := -1
for i, p := range profiles {
if strings.TrimSpace(p.UserID) == "" && strings.TrimSpace(p.Name) == selector {
return i
}
if strings.TrimSpace(p.CorpID) == selector {
if fallback < 0 {
fallback = i
@@ -486,7 +514,7 @@ func profileSwitchProfileCells(p authpkg.Profile, cfg *authpkg.ProfilesConfig) (
}
func profileSwitchProfileStatus(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
if cfg != nil && profileSelectorSelectsProfile(cfg.CurrentProfile, p, profileIsOrgCurrent(p, cfg), profileCountForCorp(cfg, p.CorpID) <= 1) {
if cfg != nil && profileSelectorSelectsProfile(cfg.CurrentProfile, p, cfg, profileIsOrgCurrent(p, cfg), profileCountForCorp(cfg, p.CorpID) <= 1) {
return "当前组织"
}
return ""
@@ -636,13 +664,14 @@ func writeProfileListTable(w io.Writer, configDir string, cfg *authpkg.ProfilesC
}
fmt.Fprintf(w, "%-3s %-28s %-34s %-10s %s\n", "CUR", "ORG_NAME", "CORP_ID", "STATUS", "USER")
for _, p := range cfg.Profiles {
selector := profileCLISelector(p, cfg)
view := profileViewFromProfile(
p,
cfg,
cfg.PrimaryProfile,
cfg.CurrentProfile,
profileCountForCorp(cfg, p.CorpID) == 1,
loadProfileTokenState(configDir, p),
loadProfileTokenState(configDir, p, selector),
)
current := ""
if view.IsCurrent {
@@ -698,13 +727,14 @@ func profileViews(configDir string, cfg *authpkg.ProfilesConfig) []profileView {
}
views := make([]profileView, 0, len(cfg.Profiles))
for _, p := range cfg.Profiles {
selector := profileCLISelector(p, cfg)
views = append(views, profileViewFromProfile(
p,
cfg,
cfg.PrimaryProfile,
cfg.CurrentProfile,
profileCountForCorp(cfg, p.CorpID) == 1,
loadProfileTokenState(configDir, p),
loadProfileTokenState(configDir, p, selector),
))
}
return views
@@ -719,7 +749,7 @@ func profileViewFromProfile(
) profileView {
isOrgCurrent := profileIsOrgCurrent(p, cfg)
view := profileView{
Profile: authpkg.ProfileSelector(p),
Profile: profileCLISelector(p, cfg),
CorpID: p.CorpID,
CorpName: profileOrgName(p),
UserID: p.UserID,
@@ -731,8 +761,8 @@ func profileViewFromProfile(
RefreshExpAt: p.RefreshExpAt,
LastLoginAt: p.LastLoginAt,
LastUsedAt: p.LastUsedAt,
IsPrimary: profileSelectorSelectsProfile(primaryProfile, p, isOrgCurrent, onlyAccountInOrg),
IsCurrent: profileSelectorSelectsProfile(currentProfile, p, isOrgCurrent, onlyAccountInOrg),
IsPrimary: profileSelectorSelectsProfile(primaryProfile, p, cfg, isOrgCurrent, onlyAccountInOrg),
IsCurrent: profileSelectorSelectsProfile(currentProfile, p, cfg, isOrgCurrent, onlyAccountInOrg),
IsOrgCurrent: isOrgCurrent,
}
if tokenState != nil {
@@ -743,8 +773,12 @@ func profileViewFromProfile(
return view
}
func loadProfileTokenState(configDir string, profile authpkg.Profile) *profileTokenState {
data, err := profileLoadTokenData(configDir, authpkg.ProfileSelector(profile))
func loadProfileTokenState(configDir string, profile authpkg.Profile, selectors ...string) *profileTokenState {
selector := authpkg.ProfileSelector(profile)
if len(selectors) > 0 && strings.TrimSpace(selectors[0]) != "" {
selector = strings.TrimSpace(selectors[0])
}
data, err := profileLoadTokenData(configDir, selector)
if errors.Is(err, authpkg.ErrTokenDataNotFound) || (err == nil && data == nil) {
return &profileTokenState{Status: authpkg.ProfileStatusRevoked}
}
@@ -762,6 +796,10 @@ func loadProfileTokenState(configDir string, profile authpkg.Profile) *profileTo
}
}
func profileCLISelector(profile authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
return authpkg.ProfileSelectionSelector(profile, cfg)
}
func profileTokenTime(value time.Time) string {
if value.IsZero() {
return ""
@@ -769,8 +807,11 @@ func profileTokenTime(value time.Time) string {
return value.Format(time.RFC3339)
}
func profileSelectorSelectsProfile(selector string, profile authpkg.Profile, isOrgCurrent, onlyAccountInOrg bool) bool {
func profileSelectorSelectsProfile(selector string, profile authpkg.Profile, cfg *authpkg.ProfilesConfig, isOrgCurrent, onlyAccountInOrg bool) bool {
selector = strings.TrimSpace(selector)
if selector == authpkg.ProfileSelectionSelector(profile, cfg) {
return true
}
if corpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact {
return corpID == strings.TrimSpace(profile.CorpID) && userID == strings.TrimSpace(profile.UserID)
}
+342 -34
View File
@@ -23,6 +23,7 @@ import (
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
@@ -40,6 +41,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
"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"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
@@ -69,7 +71,8 @@ var (
rootPluginDescriptors = (*plugin.Plugin).ToServerDescriptors
rootPluginStdioClients = (*plugin.Plugin).StdioClients
rootRegisterPluginHTTPServer = registerPluginHTTPServer
rootRegisterStdioManifest = registerStdioServerFromManifest
rootPluginStdioDescriptor = stdioServerDescriptorFromManifest
rootRegisterResolvedStdioServer = registerResolvedStdioServer
rootPluginLoadHooks = (*plugin.Plugin).LoadHooks
rootPluginSyncSkills = plugin.SyncSkills
rootAuthLoadTokenData = authpkg.LoadTokenData
@@ -306,13 +309,28 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0]
}
return NewRootCommandWithEngine(rootCtx, nil)
return newRootCommandWithEngine(rootCtx, nil, true)
}
// NewSchemaSourceRootCommand constructs the distribution-owned command tree
// used by Schema generation and command-surface policy. Installed plugins and
// user-defined shortcuts must not change the reviewed embedded Schema.
func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
var rootCtx context.Context
if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0]
}
return newRootCommandWithEngine(rootCtx, nil, false)
}
// NewRootCommandWithEngine constructs the root CLI command with an
// optional pipeline engine for input correction. When engine is nil,
// no pipeline processing is applied.
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
return newRootCommandWithEngine(rootCtx, engine, true)
}
func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool) *cobra.Command {
if rootCtx == nil {
rootCtx = context.Background()
}
@@ -336,6 +354,16 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
return cmd.Help()
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// 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.
if _, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err != nil {
return err
}
if _, err := parseAgentProduct(os.Getenv(agentproduct.EnvName)); err != nil {
return err
}
authpkg.SetRuntimeProfile(flags.Profile)
// Apply OAuth credential overrides from CLI flags (highest priority).
if flags.ClientID != "" {
@@ -368,11 +396,16 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
schemaCmd := newSchemaCommand(loader)
mcpCmd := newMCPCommand(rootCtx, loader, runner, engine)
mcpCmd.Hidden = true
// The legacy dynamic MCP surface remains disabled, but reviewed static MCP
// helpers registered below are part of the public CLI and Schema surface.
mcpCmd.Hidden = false
mcpCmd.Short = "管理 MCP 服务连接信息"
mcpCmd.Long = "管理经过审核并纳入 Schema 的 MCP 服务连接辅助能力。"
// Wrap the caller so every MCP tool call's shape is recorded to the local
// usage log (privacy-preserving; see internal/shortcut/usage). Powers
// `dws shortcut stats` and future high-frequency shortcut distillation.
patCaller := newRecordingToolCaller(newToolCallerAdapter(runner, flags))
mcpCmd.AddCommand(newMCPURLGroup(patCaller))
utilityCommands := []*cobra.Command{
newAuthCommand(patCaller),
@@ -396,16 +429,9 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
}
root.AddCommand(utilityCommands...)
root.AddCommand(newLegacyPublicCommands(runner, patCaller)...)
root.AddCommand(newLegacyPublicCommands(runner, patCaller, loadRuntimeExtensions)...)
root.AddCommand(newLegacyHiddenCommands(runner)...)
// --- Plugin loading: runs AFTER legacy commands so plugin endpoints can
// be appended on top of the static endpoint registry.
pluginCmds := rootLoadPlugins(engine, runner)
if len(pluginCmds) > 0 {
addPluginCommandsSafe(root, pluginCmds)
}
// PAT authorization commands (open-source core)
pat.RegisterCommands(root, patCaller)
@@ -414,6 +440,15 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
fn(root, caller)
deduplicateCommands(root)
}
if loadRuntimeExtensions {
// Resolve plugins only after the complete distribution command tree is
// present, so endpoint and Cobra conflict checks see PAT and edition
// commands as well as the open-source base.
pluginCmds := rootLoadPlugins(root, engine, runner)
if len(pluginCmds) > 0 {
addPluginCommandsSafe(root, pluginCmds)
}
}
hideNonDirectRuntimeCommands(root)
configureRootHelp(root)
// Set custom flag error handler for better UX
@@ -600,6 +635,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
"profile": true,
"version": true,
"help": true,
"markdown": true,
"recovery": true,
"schema": true,
"mcp": true,
@@ -631,12 +667,17 @@ var reservedCommands = map[string]bool{
"schema": true, "mcp": true, "help": true,
}
var replaceablePluginFallbacks = map[string]bool{
"conference": true,
}
// addPluginCommandsSafe registers plugin commands with conflict detection.
//
// Rules:
// - Plugin vs reserved (auth/plugin/cache/...) → reject, warn
// - Plugin vs plugin (same name) → reject later one, warn
// - Plugin vs Market dynamic command → allow, plugin wins
// - Plugin vs hidden compatibility fallback → allow, plugin wins
// - Plugin vs visible distribution command → reject, warn
func addPluginCommandsSafe(root *cobra.Command, pluginCmds []*cobra.Command) {
// Build index of existing commands before plugin registration.
existing := make(map[string]bool)
@@ -664,17 +705,47 @@ func addPluginCommandsSafe(root *cobra.Command, pluginCmds []*cobra.Command) {
}
pluginSeen[name] = true
// Rule 3: plugin vs Market — plugin wins, remove the old one.
// An alias must not bypass the same protections applied to primary
// plugin command names or shadow another root command.
filteredAliases := make([]string, 0, len(cmd.Aliases))
for _, rawAlias := range cmd.Aliases {
alias := strings.TrimSpace(rawAlias)
if alias == "" || alias == name || reservedCommands[alias] ||
existing[alias] || pluginSeen[alias] {
if alias != "" {
slog.Warn("plugin: command alias conflicts with an existing command, skipping",
"command", name, "alias", alias)
}
continue
}
pluginSeen[alias] = true
filteredAliases = append(filteredAliases, alias)
}
cmd.Aliases = filteredAliases
// Rule 3: an installed plugin may replace a hidden compatibility
// fallback (for example conference), but never a visible distribution
// command that participates in the reviewed base interface.
if existing[name] {
for _, old := range root.Commands() {
if old.Name() == name {
if !old.Hidden || !replaceablePluginFallbacks[name] ||
cmdutil.IsPluginSourced(old) {
slog.Warn("plugin: command conflicts with a visible distribution command, skipping",
"command", name)
cmd = nil
break
}
root.RemoveCommand(old)
slog.Debug("plugin: overriding Market command",
slog.Debug("plugin: overriding hidden compatibility command",
"command", name)
break
}
}
}
if cmd == nil {
continue
}
root.AddCommand(cmd)
}
@@ -811,7 +882,21 @@ func CloseFileLogger() {
// loadPlugins registers versioned plugin manifests, stdio clients, hooks, and
// skills. It deliberately does not initialize MCP transports or call
// tools/list while constructing the command tree.
func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
type pluginServerCandidate struct {
owner *plugin.Plugin
order int
descriptor mcptypes.ServerDescriptor
stdioClient *plugin.StdioServerClient
}
type pluginIdentityOwner struct {
plugin *plugin.Plugin
serverKey string
rootName string
shareable bool
}
func loadPlugins(root *cobra.Command, engine *pipeline.Engine, runner executor.Runner) []*cobra.Command {
pluginLoader := plugin.NewLoader(RawVersion())
// 0a. Inject plugin config values from settings.json as environment
@@ -838,25 +923,34 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
// 2. Load dev plugins (registered via `dws plugin dev`)
devPlugins := rootPluginLoadDev(pluginLoader)
sortPluginsForRegistration(userPlugins)
sortPluginsForRegistration(devPlugins)
allPlugins := append(userPlugins, devPlugins...)
descriptorsByPlugin := make(map[*plugin.Plugin][]mcptypes.ServerDescriptor, len(allPlugins))
// 3. Register HTTP descriptors and authentication from the manifest.
for _, p := range allPlugins {
for _, srv := range rootPluginDescriptors(p) {
rootRegisterPluginHTTPServer(srv)
// 3. Resolve every descriptor once, then choose identity winners before
// mutating endpoint, auth, or stdio-client registries. This keeps the
// visible command and its transport owned by the same plugin.
candidates := collectPluginServerCandidates(allPlugins, userCtx)
accepted := selectPluginServerCandidates(root, candidates)
for _, candidate := range accepted {
if candidate.stdioClient != nil {
rootRegisterResolvedStdioServer(
candidate.owner,
*candidate.stdioClient,
candidate.descriptor,
)
} else {
rootRegisterPluginHTTPServer(candidate.descriptor)
}
descriptorsByPlugin[candidate.owner] = append(
descriptorsByPlugin[candidate.owner],
candidate.descriptor,
)
}
// 4. Register stdio descriptors and unstarted clients. The subprocess is
// started and initialized only when a command is actually executed.
for _, p := range allPlugins {
for _, sc := range rootPluginStdioClients(p, userCtx) {
rootRegisterStdioManifest(p, sc)
}
}
// 5. Register plugin hooks into pipeline engine
// 4. Register plugin hooks into pipeline engine
if engine != nil {
for _, p := range allPlugins {
hooksCfg, err := rootPluginLoadHooks(p)
@@ -874,7 +968,7 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
}
}
// 7. Sync plugin skills to agent directories
// 5. Sync plugin skills to agent directories
rootPluginSyncSkills(allPlugins)
if len(allPlugins) > 0 {
@@ -884,11 +978,228 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
)
}
return nil
var pluginCommands []*cobra.Command
for _, p := range allPlugins {
// Build each plugin independently. addPluginCommandsSafe deliberately
// resolves cross-plugin root conflicts with first-plugin-wins semantics.
pluginCommands = append(pluginCommands, buildPluginCommands(descriptorsByPlugin[p], runner, root)...)
}
return pluginCommands
}
func sortPluginsForRegistration(plugins []*plugin.Plugin) {
sort.SliceStable(plugins, func(i, j int) bool {
left := strings.TrimSpace(plugins[i].Manifest.Name) + "\x00" + strings.TrimSpace(plugins[i].Root)
right := strings.TrimSpace(plugins[j].Manifest.Name) + "\x00" + strings.TrimSpace(plugins[j].Root)
return left < right
})
}
func collectPluginServerCandidates(
plugins []*plugin.Plugin,
userCtx *plugin.UserContext,
) []pluginServerCandidate {
var candidates []pluginServerCandidate
for order, owner := range plugins {
for _, descriptor := range rootPluginDescriptors(owner) {
candidates = append(candidates, pluginServerCandidate{
owner: owner,
order: order,
descriptor: descriptor,
})
}
for _, stdioClient := range rootPluginStdioClients(owner, userCtx) {
descriptor, ok := rootPluginStdioDescriptor(owner, stdioClient)
if !ok {
continue
}
clientCopy := stdioClient
candidates = append(candidates, pluginServerCandidate{
owner: owner,
order: order,
descriptor: descriptor,
stdioClient: &clientCopy,
})
}
}
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].order != candidates[j].order {
return candidates[i].order < candidates[j].order
}
left := strings.TrimSpace(candidates[i].descriptor.Key)
right := strings.TrimSpace(candidates[j].descriptor.Key)
if left != right {
return left < right
}
return candidates[i].stdioClient == nil && candidates[j].stdioClient != nil
})
return candidates
}
func selectPluginServerCandidates(
root *cobra.Command,
candidates []pluginServerCandidate,
) []pluginServerCandidate {
distributionProducts := DirectRuntimeProductIDs()
owners := make(map[string]pluginIdentityOwner)
for identity := range distributionProducts {
if replaceablePluginFallbacks[identity] {
continue
}
owners[identity] = pluginIdentityOwner{serverKey: "distribution"}
}
accepted := make([]pluginServerCandidate, 0, len(candidates))
for _, candidate := range candidates {
descriptor := candidate.descriptor
if descriptor.CLI.Skip {
continue
}
if reason := unsupportedPluginDescriptor(root, descriptor); reason != "" {
slog.Warn("plugin: descriptor CLI semantics are unsupported, skipping",
"plugin", candidate.owner.Manifest.Name,
"server", descriptor.Key,
"field", reason)
continue
}
if pluginDescriptorConflictsWithDistribution(root, descriptor, distributionProducts) {
continue
}
claims := pluginDescriptorIdentityClaims(descriptor)
conflict := ""
for identity, shareable := range claims {
existing, exists := owners[identity]
if !exists {
continue
}
rootName := pluginDescriptorRootName(descriptor)
if shareable && existing.shareable &&
existing.plugin == candidate.owner &&
existing.rootName == rootName {
continue
}
conflict = identity
break
}
if conflict != "" {
slog.Warn("plugin: descriptor identity already owned, skipping",
"plugin", candidate.owner.Manifest.Name,
"server", descriptor.Key,
"identity", conflict)
continue
}
rootName := pluginDescriptorRootName(descriptor)
for identity, shareable := range claims {
if existing, exists := owners[identity]; exists &&
shareable && existing.shareable &&
existing.plugin == candidate.owner &&
existing.rootName == rootName {
continue
}
owners[identity] = pluginIdentityOwner{
plugin: candidate.owner,
serverKey: descriptor.Key,
rootName: rootName,
shareable: shareable,
}
}
accepted = append(accepted, candidate)
}
return accepted
}
func pluginDescriptorIdentityClaims(descriptor mcptypes.ServerDescriptor) map[string]bool {
claims := make(map[string]bool)
canonicalID := firstNonEmptyPluginString(descriptor.CLI.ID, descriptor.Key)
if canonicalID != "" {
claims[canonicalID] = false
}
for _, identity := range append(
[]string{pluginDescriptorRootName(descriptor)},
descriptor.CLI.Aliases...,
) {
identity = strings.TrimSpace(identity)
if identity == "" {
continue
}
if _, exists := claims[identity]; !exists {
claims[identity] = true
}
}
return claims
}
func pluginDescriptorRootName(descriptor mcptypes.ServerDescriptor) string {
return firstNonEmptyPluginString(
descriptor.CLI.Command,
descriptor.CLI.ID,
descriptor.Key,
)
}
func pluginDescriptorConflictsWithDistribution(
root *cobra.Command,
descriptor mcptypes.ServerDescriptor,
distributionProducts map[string]bool,
) bool {
candidates := append(
[]string{
firstNonEmptyPluginString(descriptor.CLI.ID, descriptor.Key),
pluginDescriptorRootName(descriptor),
},
descriptor.CLI.Aliases...,
)
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" {
continue
}
if !reservedCommands[candidate] && replaceablePluginFallbacks[candidate] {
// The distribution ships only a hidden compatibility fallback for
// this name; plugins may claim it and the later command merge in
// addPluginCommandsSafe still rejects visible non-fallback owners.
continue
}
if reservedCommands[candidate] ||
distributionProducts[candidate] ||
distributionRootOwns(root, candidate) {
slog.Warn("plugin: descriptor conflicts with a distribution command, skipping",
"plugin", descriptor.DisplayName,
"server", descriptor.Key,
"identity", candidate)
return true
}
}
return false
}
func distributionRootOwns(root *cobra.Command, name string) bool {
if root == nil {
return false
}
for _, command := range root.Commands() {
if cmdutil.IsPluginSourced(command) {
continue
}
if command.Name() == name {
if command.Hidden && replaceablePluginFallbacks[name] {
return false
}
return true
}
for _, alias := range command.Aliases {
if strings.TrimSpace(alias) == name {
return true
}
}
}
return false
}
func registerPluginHTTPServer(srv mcptypes.ServerDescriptor) {
AppendDynamicServer(srv)
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
ClearPluginAuth(productID)
if len(srv.AuthHeaders) > 0 {
registerPluginAuthFromHeaders(srv)
}
@@ -917,10 +1228,7 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
host := parsed.Hostname()
trustedDomains = []string{host, "*." + host}
}
productID := strings.TrimSpace(srv.CLI.ID)
if productID == "" {
productID = srv.Key
}
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
RegisterPluginAuth(productID, &PluginAuth{
Token: authToken,
ExtraHeaders: extraHeaders,
+33 -8
View File
@@ -75,7 +75,7 @@ func TestCrossPlatformCoverageRootConstructionHooksAndVersionCoverage(t *testing
version, buildTime, gitCommit = oldVersion, oldBuild, oldCommit
})
rootLoadPlugins = func(*pipeline.Engine, executor.Runner) []*cobra.Command {
rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
return []*cobra.Command{{Use: "plugin-added", Run: func(*cobra.Command, []string) {}}}
}
preRunCalled := false
@@ -236,7 +236,8 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
oldDescriptors := rootPluginDescriptors
oldStdioClients := rootPluginStdioClients
oldHTTP := rootRegisterPluginHTTPServer
oldStdio := rootRegisterStdioManifest
oldStdioDescriptor := rootPluginStdioDescriptor
oldStdioRegister := rootRegisterResolvedStdioServer
oldHooks := rootPluginLoadHooks
oldSync := rootPluginSyncSkills
oldToken := rootAuthLoadTokenData
@@ -247,7 +248,8 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
rootPluginDescriptors = oldDescriptors
rootPluginStdioClients = oldStdioClients
rootRegisterPluginHTTPServer = oldHTTP
rootRegisterStdioManifest = oldStdio
rootPluginStdioDescriptor = oldStdioDescriptor
rootRegisterResolvedStdioServer = oldStdioRegister
rootPluginLoadHooks = oldHooks
rootPluginSyncSkills = oldSync
rootAuthLoadTokenData = oldToken
@@ -264,9 +266,17 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
}
rootPluginDescriptors = func(p *plugin.Plugin) []mcptypes.ServerDescriptor {
if p == p1 {
return []mcptypes.ServerDescriptor{{Key: "http", Endpoint: "https://example.test"}}
return []mcptypes.ServerDescriptor{{
Key: "http", Endpoint: "https://example.test",
CLI: mcptypes.CLIOverlay{
ID: "http", Command: "one-http",
ToolOverrides: map[string]mcptypes.CLIToolOverride{
"ping": {CLIName: "ping"},
},
},
}}
}
return []mcptypes.ServerDescriptor{{Key: "no-cli", Endpoint: "https://example.test"}}
return []mcptypes.ServerDescriptor{{Key: p.Manifest.Name + "-no-cli", Endpoint: "https://example.test"}}
}
client := transport.NewStdioClient("ignored", nil, nil)
rootPluginStdioClients = func(p *plugin.Plugin, uc *plugin.UserContext) []plugin.StdioServerClient {
@@ -278,9 +288,23 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
httpCount := 0
stdioCount := 0
rootRegisterPluginHTTPServer = func(mcptypes.ServerDescriptor) { httpCount++ }
rootRegisterStdioManifest = func(*plugin.Plugin, plugin.StdioServerClient) mcptypes.ServerDescriptor {
rootPluginStdioDescriptor = func(*plugin.Plugin, plugin.StdioServerClient) (mcptypes.ServerDescriptor, bool) {
return mcptypes.ServerDescriptor{
Key: "local",
CLI: mcptypes.CLIOverlay{
ID: "local", Command: "one-stdio",
ToolOverrides: map[string]mcptypes.CLIToolOverride{
"pong": {CLIName: "pong"},
},
},
}, true
}
rootRegisterResolvedStdioServer = func(
*plugin.Plugin,
plugin.StdioServerClient,
mcptypes.ServerDescriptor,
) {
stdioCount++
return mcptypes.ServerDescriptor{}
}
rootPluginLoadHooks = func(p *plugin.Plugin) (*plugin.HooksConfig, error) {
switch p {
@@ -294,7 +318,8 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
}
synced := false
rootPluginSyncSkills = func([]*plugin.Plugin) { synced = true }
if got := loadPlugins(pipeline.NewEngine(), runnerCoverageFallback{}); got != nil {
got := loadPlugins(nil, pipeline.NewEngine(), runnerCoverageFallback{})
if len(got) != 2 || got[0].Name() != "one-http" || got[1].Name() != "one-stdio" {
t.Fatalf("loaded plugin commands = %#v", got)
}
if httpCount != 3 || stdioCount != 1 || !synced {
+2
View File
@@ -5,6 +5,7 @@ import (
"strings"
"text/tabwriter"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -41,6 +42,7 @@ func configureRootHelp(root *cobra.Command) {
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
if cmd != root {
defaultHelpFunc(cmd, args)
cli.RenderSafetyAnnotation(cmd)
return
}
renderRootHelp(root)
+119 -2
View File
@@ -15,11 +15,14 @@ package app
import (
"bytes"
stderrors "errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -75,7 +78,14 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
}
}
mediaUpload := mustFindCommand(t, root, "chat", "media", "upload")
mediaGroup := mustFindCommand(t, root, "chat", "media")
if mediaGroup.Deprecated == "" || mediaGroup.Hidden || !mediaGroup.Runnable() {
t.Fatalf("chat media compatibility contract: deprecated=%q hidden=%v runnable=%v", mediaGroup.Deprecated, mediaGroup.Hidden, mediaGroup.Runnable())
}
mediaUpload := mustFindCommand(t, mediaGroup, "upload")
if mediaUpload.Deprecated == "" || mediaUpload.Hidden || !mediaUpload.Runnable() {
t.Fatalf("chat media upload compatibility contract: deprecated=%q hidden=%v runnable=%v", mediaUpload.Deprecated, mediaUpload.Hidden, mediaUpload.Runnable())
}
for _, flag := range []string{"file", "type"} {
if mediaUpload.Flags().Lookup(flag) == nil {
t.Fatalf("chat media upload missing --%s", flag)
@@ -88,6 +98,113 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
mustFindCommand(t, root, "conference", "meeting", "reserve")
}
func TestChatHelpAndSchemaHideRetiredMediaUpload(t *testing.T) {
for _, args := range [][]string{
{"chat", "--help"},
{"chat", "media", "--help"},
} {
root := NewRootCommand()
var output bytes.Buffer
root.SetOut(&output)
root.SetErr(&output)
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatalf("dws %s: %v\n%s", strings.Join(args, " "), err, output.String())
}
for _, line := range strings.Split(output.String(), "\n") {
fields := strings.Fields(line)
if len(fields) > 0 && (fields[0] == "media" || fields[0] == "upload") {
t.Fatalf("dws %s exposes retired command in Help line %q:\n%s", strings.Join(args, " "), line, output.String())
}
}
}
root := NewRootCommand()
var output bytes.Buffer
root.SetOut(&output)
root.SetErr(&output)
root.SetArgs([]string{"schema", "--cli-path", "chat media upload", "--format", "json"})
err := root.Execute()
if err == nil {
t.Fatalf("retired chat media upload remains queryable from Schema:\n%s", output.String())
}
if !strings.Contains(err.Error(), "unknown runtime schema path") {
t.Fatalf("retired chat media upload Schema error = %v, want unknown path", err)
}
}
func TestRootChatMediaUploadWithoutAppCredentialsReturnsMigrationValidation(t *testing.T) {
for _, key := range []string{"DWS_CLIENT_ID", "DWS_CLIENT_SECRET"} {
value, existed := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatalf("unset %s: %v", key, err)
}
t.Cleanup(func() {
if existed {
_ = os.Setenv(key, value)
return
}
_ = os.Unsetenv(key)
})
if _, exists := os.LookupEnv(key); exists {
t.Fatalf("%s is still set", key)
}
}
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
filePath := filepath.Join(t.TempDir(), "image.png")
if err := os.WriteFile(filePath, []byte("image"), 0o600); err != nil {
t.Fatalf("write image fixture: %v", err)
}
commandArgs := []string{
"chat", "media", "upload",
"--file", filePath,
"--type", "image",
}
previousArgs := os.Args
os.Args = append([]string{"dws"}, commandArgs...)
t.Cleanup(func() { os.Args = previousArgs })
root := NewRootCommand()
var output bytes.Buffer
root.SetOut(&output)
root.SetErr(&output)
root.SetArgs(commandArgs)
err := root.Execute()
if err == nil {
t.Fatalf("chat media upload succeeded without app credentials:\n%s", output.String())
}
var typed *apperrors.Error
if !stderrors.As(err, &typed) {
t.Fatalf("chat media upload error type = %T, want *errors.Error: %v", err, err)
}
if typed.Category != apperrors.CategoryValidation {
t.Fatalf("chat media upload category = %q, want %q", typed.Category, apperrors.CategoryValidation)
}
if exitCode := apperrors.ExitCode(err); exitCode != 3 {
t.Fatalf("chat media upload exit code = %d, want 3", exitCode)
}
got := output.String() + "\n" + err.Error()
for _, want := range []string{"已下线", "chat message send --msg-type file --file-path"} {
if !strings.Contains(got, want) {
t.Fatalf("chat media upload migration output missing %q:\n%s", want, got)
}
}
for _, forbidden := range []string{
"DWS_CLIENT_ID",
"DWS_CLIENT_SECRET",
"缺少应用凭证",
"AppSecret",
"clientSecret",
} {
if strings.Contains(got, forbidden) {
t.Fatalf("chat media upload returned credential error %q:\n%s", forbidden, got)
}
}
}
func TestRootKeepsContactWukongCompatibilityCommands(t *testing.T) {
root := NewRootCommand()
label := mustFindCommand(t, root, "contact", "label")
@@ -263,7 +380,7 @@ func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
}
searchAdvanced := mustFindCommand(t, root, "chat", "message", "search-advanced")
for _, flag := range []string{"sender", "senders", "sender-ids"} {
for _, flag := range []string{"sender", "senders", "sender-ids", "message-type", "only-robot", "conversation-type"} {
if searchAdvanced.Flags().Lookup(flag) == nil {
t.Fatalf("chat message search-advanced missing --%s", flag)
}
+92 -8
View File
@@ -36,6 +36,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
"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/configmeta"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -168,6 +169,7 @@ var (
runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload
runnerCallTool = (*transport.Client).CallTool
runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized
runnerStdioListTools = (*transport.StdioClient).ListTools
runnerStdioCallTool = (*transport.StdioClient).CallTool
runnerHandlePatAuthCheck func(context.Context, *runtimeRunner, executor.Invocation, *apperrors.PATError, string, io.Writer) (executor.Result, error)
runnerRetryWithPatAuthRetry func(context.Context, executor.Runner, executor.Invocation, *PatScopeError, string, io.Writer) (executor.Result, error)
@@ -209,13 +211,38 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
if profile == nil {
return executor.Result{}, apperrors.NewValidation(fmt.Sprintf("profile %q not found", rawProfile))
}
authpkg.SetRuntimeProfile(authpkg.ProfileSelector(*profile))
resolvedSelector := authpkg.ProfileSelector(*profile)
if strings.TrimSpace(profile.UserID) == "" {
// Preserve a unique local-name selector for an unresolved account.
// Reducing it to corpId can select a different exact account through
// the organization's current-account pointer.
resolvedSelector = rawProfile
}
authpkg.SetRuntimeProfile(resolvedSelector)
defer authpkg.SetRuntimeProfile(rawProfile)
}
return r.runSingle(ctx, invocation, true)
}
// RunReadOnly executes one already-classified read lookup for a semantic
// Shortcut that is building a dry-run plan. It clones the runtime flags and
// clears DryRun only on that clone: the process-wide caller and every ordinary
// ToolCaller invocation retain the global execution barrier.
func (r *runtimeRunner) RunReadOnly(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
if r == nil {
return executor.Result{}, fmt.Errorf("runtime runner is not configured")
}
clone := *r
if r.globalFlags != nil {
flags := *r.globalFlags
flags.DryRun = false
clone.globalFlags = &flags
}
invocation.DryRun = false
return clone.Run(ctx, invocation)
}
func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invocation, prefetchToken bool) (executor.Result, error) {
if r.loader == nil || r.transport == nil {
return r.fallback.Run(ctx, invocation)
@@ -350,6 +377,9 @@ func (r *runtimeRunner) runMultiProfile(ctx context.Context, invocation executor
for _, selection := range selections {
resolvedSelector := authpkg.ProfileSelector(selection.Profile)
if strings.TrimSpace(selection.Profile.UserID) == "" {
resolvedSelector = selection.Selector
}
authpkg.SetRuntimeProfile(resolvedSelector)
result, err := r.runSingle(ctx, cloneInvocation(invocation), false)
@@ -485,7 +515,7 @@ func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation execut
func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (result executor.Result, retErr error) {
// Route stdio:// endpoints to the local StdioClient — no HTTP, no auth.
if IsStdioEndpoint(endpoint) {
return r.executeStdioInvocation(ctx, invocation)
return r.executeStdioInvocationAtEndpoint(ctx, endpoint, invocation)
}
// Constructing the Cobra tree is also used for help, schema, and command
@@ -766,6 +796,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
// subprocess instead of the HTTP transport. This is used for plugin stdio
// servers whose endpoints use the stdio:// scheme.
func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
return r.executeStdioInvocationAtEndpoint(ctx, "", invocation)
}
func (r *runtimeRunner) executeStdioInvocationAtEndpoint(
ctx context.Context,
endpoint string,
invocation executor.Invocation,
) (executor.Result, error) {
if invocation.DryRun {
return executor.Result{
Invocation: invocation,
@@ -778,10 +816,14 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
}, nil
}
client, ok := LookupStdioClient(invocation.CanonicalProduct)
lookupKey := strings.Trim(strings.TrimPrefix(strings.TrimSpace(endpoint), stdioEndpointScheme), "/")
if lookupKey == "" {
lookupKey = invocation.CanonicalProduct
}
client, ok := LookupStdioClient(lookupKey)
if !ok {
return executor.Result{}, apperrors.NewInternal(
fmt.Sprintf("stdio client not found for %q", invocation.CanonicalProduct))
fmt.Sprintf("stdio client not found for %q", lookupKey))
}
callCtx := ctx
@@ -798,6 +840,27 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
)
}
tools, err := runnerStdioListTools(client, callCtx)
if err != nil {
return executor.Result{}, apperrors.NewAPI(
fmt.Sprintf("stdio tools/list failed: %v", err),
apperrors.WithOperation("tools/list"),
apperrors.WithReason("stdio_tools_list_error"),
)
}
schema, ok := pluginToolInputSchema(tools, invocation.Tool)
if !ok {
return executor.Result{}, apperrors.NewValidation(
fmt.Sprintf("plugin tool %q is not declared by tools/list", invocation.Tool),
apperrors.WithReason("plugin_tool_not_found"),
)
}
normalizedParams, err := normalizePluginInputParams(invocation.Params, schema)
if err != nil {
return executor.Result{}, err
}
invocation.Params = normalizedParams
callResult, err := runnerStdioCallTool(client, callCtx, invocation.Tool, invocation.Params)
if err != nil {
return executor.Result{}, apperrors.NewAPI(
@@ -942,10 +1005,10 @@ func resolveIdentityHeaders() map[string]string {
// Inject environment variable based headers for MCP gateway tracking.
// DINGTALK_AGENT, if set by the caller, is forwarded verbatim as the
// x-dingtalk-agent header. It does NOT influence claw-type (which the
// open-source edition pins to edition.DefaultOSSClawType via the
// MergeHeaders hook below) and it does NOT influence the host-owned
// PAT decision (driven solely by DINGTALK_DWS_AGENTCODE).
// x-dingtalk-agent header. It does NOT influence claw-type (which comes
// from the edition default plus the explicit DWS_AGENT_PRODUCT override)
// and it does NOT influence the host-owned PAT decision (driven solely by
// DINGTALK_DWS_AGENTCODE).
sessionID := os.Getenv(envDingtalkSessionID)
if sessionID == "" {
sessionID = os.Getenv(envDWSSessionID)
@@ -994,12 +1057,33 @@ func resolveIdentityHeaders() map[string]string {
headers["x-dws-channel"] = v
}
// DWS_AGENT_HOST is a caller-declared runtime-form signal. Root command
// execution validates it strictly in PersistentPreRunE. Library callers
// that bypass the root command keep this best-effort API contract: invalid
// values are omitted rather than changing the public function signature.
if agentHost, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err == nil && agentHost != "" {
headers[headerDWSAgentHost] = agentHost
}
if fn := edition.Get().MergeHeaders; fn != nil {
headers = fn(headers)
}
// Resolve the Agent Product before credential injection. The credential
// hook has a separate contract and must not be able to replace the
// request identity used by PAT hostControl serialization.
headers = applyAgentProductOverride(headers)
agentProduct := headers[agentproduct.HeaderName]
if fn := edition.Get().EnterpriseCredentialHeaders; fn != nil {
headers = fn(headers)
}
if headers == nil {
headers = make(map[string]string)
}
// DWS_AGENT_PRODUCT is the explicit caller override for the existing
// claw-type wire header. Reassert the resolved product after credential
// injection so that hook cannot alter identity. Invalid values are ignored
// on this best-effort library path; root execution rejects them earlier.
headers[agentproduct.HeaderName] = agentProduct
return headers
}
+20
View File
@@ -290,10 +290,12 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *testing.T) {
oldStdioInit := runnerStdioEnsureInitialized
oldStdioList := runnerStdioListTools
oldStdioCall := runnerStdioCallTool
oldEdition := edition.Get()
t.Cleanup(func() {
runnerStdioEnsureInitialized = oldStdioInit
runnerStdioListTools = oldStdioList
runnerStdioCallTool = oldStdioCall
edition.Override(oldEdition)
StopAllStdioClients()
@@ -309,6 +311,14 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
t.Fatalf("stdio initialize error = %v", err)
}
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return nil }
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
return transport.ToolsListResult{
Tools: []transport.ToolDescriptor{{
Name: "tool",
InputSchema: map[string]any{"type": "object"},
}},
}, nil
}
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{}, wantErr
}
@@ -327,6 +337,16 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
if got, err := r.executeStdioInvocation(context.Background(), inv); err != nil || !got.Invocation.Implemented {
t.Fatalf("stdio success = %#v, %v", got, err)
}
RegisterStdioClient("plugin/server-key", client)
overlayIDInvocation := inv
overlayIDInvocation.CanonicalProduct = "overlay-id"
if got, err := r.executeInvocation(
context.Background(),
"stdio://plugin/server-key",
overlayIDInvocation,
); err != nil || !got.Invocation.Implemented {
t.Fatalf("stdio endpoint-key lookup = %#v, %v", got, err)
}
r.globalFlags.Token = " explicit "
if got, err := r.resolveAuthToken(context.Background()); err != nil || got != "explicit" {
+1 -1
View File
@@ -14,7 +14,7 @@ func TestRuntimeSchemaCompletenessCoversPublicCommandTree(t *testing.T) {
if err != nil {
t.Fatal(err)
}
root := NewRootCommand()
root := NewSchemaSourceRootCommand()
if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(root); err != nil {
t.Fatal(err)
}
@@ -0,0 +1,410 @@
// 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"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const (
publicShortcutCount = 265
schemaPublishedShortcutCount = 210
)
func TestEmbeddedSchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
tools := embeddedSchemaAllToolsForHelpFlagTest(t, NewRootCommand())
public := make([]shortcut.Shortcut, 0, publicShortcutCount)
for _, candidate := range shortcut.All() {
if candidate.UserDefined || !shortcut.InPublicCatalog(candidate.Service, candidate.Command) {
continue
}
public = append(public, candidate)
}
if got := len(public); got != publicShortcutCount {
t.Fatalf("public built-in shortcuts = %d, want %d", got, publicShortcutCount)
}
deliveredShortcuts := 0
for canonical := range tools {
if strings.Contains(canonical, ".shortcut_") {
deliveredShortcuts++
}
}
if deliveredShortcuts != schemaPublishedShortcutCount {
t.Fatalf("embedded schema --all shortcut tools = %d, want %d", deliveredShortcuts, schemaPublishedShortcutCount)
}
exclusions, err := cli.EmbeddedRuntimeSchemaExclusions()
if err != nil {
t.Fatal(err)
}
excludedPaths := make(map[string]bool, len(exclusions))
for _, exclusion := range exclusions {
if !exclusion.Reviewed || strings.TrimSpace(exclusion.Reason) == "" {
t.Fatalf("unreviewed public command exclusion: %#v", exclusion)
}
excludedPaths[exclusion.CLIPath] = true
}
excludedShortcuts := 0
for _, declared := range public {
declared := declared
t.Run(declared.Service+"/"+strings.TrimPrefix(declared.Command, "+"), func(t *testing.T) {
canonical := shortcutSchemaCanonical(declared)
tool := tools[canonical]
if tool == nil {
cliPath := declared.Service + " " + declared.Command
if !excludedPaths[cliPath] {
t.Fatalf("embedded schema --all is missing %s (%s) without an exact reviewed exclusion", canonical, cliPath)
}
excludedShortcuts++
return
}
assertEmbeddedShortcutIdentityAndSelection(t, tool, declared, canonical)
assertEmbeddedShortcutSafetyAndInterface(t, tool, declared, canonical)
assertEmbeddedShortcutParameters(t, tool, declared, canonical)
assertEmbeddedShortcutConstraints(t, tool, declared, canonical)
})
}
if got, want := excludedShortcuts, publicShortcutCount-schemaPublishedShortcutCount; got != want {
t.Fatalf("exactly excluded public shortcuts = %d, want %d", got, want)
}
}
func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", "chat +messages-read-status")
if got, want := schemaContractString(leaf["canonical_path"]), "chat.shortcut_messages_read_status"; got != want {
t.Fatalf("shortcut leaf canonical_path = %q, want %q", got, want)
}
if got, want := schemaContractString(leaf["confirmation"]), "not_required"; got != want {
t.Fatalf("shortcut leaf confirmation = %q, want %q", got, want)
}
conversationID := schemaContractMap(leaf["parameters"])["conversation-id"]
if required, _ := conversationID["required"].(bool); !required {
t.Fatal("public --conversation-id must become required after hidden compatibility aliases are removed from Schema")
}
if got := leaf["constraints"]; got != nil {
t.Fatalf("shortcut leaf constraints = %#v, want omitted after hidden compatibility aliases collapse", got)
}
constrainedLeaf := executeShortcutSchemaQuery(t, "--cli-path", "calendar +freebusy")
wantConstraints := map[string]any{
"require_one_of": [][]string{{"users", "rooms"}},
}
if got := constrainedLeaf["constraints"]; !schemaContractJSONEqual(got, wantConstraints) {
t.Fatalf("shortcut leaf constraints = %#v, want %#v", got, wantConstraints)
}
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 124; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
shortcutCount := 0
for _, summary := range summaries {
if strings.HasPrefix(schemaContractString(summary["canonical_path"]), "chat.shortcut_") {
shortcutCount++
}
}
if shortcutCount != 42 {
t.Fatalf("schema chat shortcut summaries = %d, want 42", shortcutCount)
}
}
func executeShortcutSchemaQuery(t testing.TB, args ...string) map[string]any {
t.Helper()
root := NewRootCommand()
var stdout, stderr bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&stderr)
root.SetArgs(append([]string{"schema"}, append(args, "--format", "json")...))
if err := root.Execute(); err != nil {
t.Fatalf("execute dws schema %q: %v; stderr=%s", strings.Join(args, " "), err, stderr.String())
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode dws schema %q: %v", strings.Join(args, " "), err)
}
return payload
}
func shortcutSchemaCanonical(declared shortcut.Shortcut) string {
name := strings.ReplaceAll(strings.TrimPrefix(declared.Command, "+"), "-", "_")
return declared.Service + ".shortcut_" + name
}
func assertEmbeddedShortcutIdentityAndSelection(
t testing.TB,
tool map[string]any,
declared shortcut.Shortcut,
canonical string,
) {
t.Helper()
if got, want := schemaContractString(tool["canonical_path"]), canonical; got != want {
t.Errorf("canonical_path = %q, want %q", got, want)
}
if got, want := schemaContractString(tool["primary_cli_path"]), declared.Service+" "+declared.Command; got != want {
t.Errorf("%s primary_cli_path = %q, want %q", canonical, got, want)
}
if got, want := schemaContractString(tool["agent_summary"]), declared.Description; got != want {
t.Errorf("%s agent_summary = %q, want %q", canonical, got, want)
}
if got, want := schemaContractStringSlice(tool["use_when"]), []string{declared.Intent}; !schemaContractJSONEqual(got, want) {
t.Errorf("%s use_when = %#v, want %#v", canonical, got, want)
}
if len(schemaContractStringSlice(tool["avoid_when"])) == 0 {
t.Errorf("%s has no reviewed avoid_when", canonical)
}
examples := schemaContractStringSlice(tool["examples"])
if len(examples) == 0 || len(examples) > 2 {
t.Errorf("%s examples = %d, want 1..2", canonical, len(examples))
}
for _, example := range examples {
if strings.Contains(example, "--yes") {
t.Errorf("%s stores unsafe example %q", canonical, example)
}
if !strings.HasPrefix(example, "dws "+declared.Service+" "+declared.Command) {
t.Errorf("%s example does not use its primary path: %q", canonical, example)
}
}
}
func assertEmbeddedShortcutSafetyAndInterface(
t testing.TB,
tool map[string]any,
declared shortcut.Shortcut,
canonical string,
) {
t.Helper()
risk := declared.Risk
if risk == "" {
risk = shortcut.RiskRead
}
wantEffect, wantRisk, wantConfirmation, wantIdempotency := "read", "low", "not_required", "idempotent"
switch risk {
case shortcut.RiskWrite:
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "write", "medium", "user_required", "unknown"
case shortcut.RiskHighWrite:
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "destructive", "high", "user_required", "unknown"
}
for field, want := range map[string]string{
"effect": wantEffect,
"risk": wantRisk,
"confirmation": wantConfirmation,
"idempotency": wantIdempotency,
"interface_mode": "composite",
"availability": "available",
} {
if got := schemaContractString(tool[field]); got != want {
t.Errorf("%s %s = %q, want %q", canonical, field, got, want)
}
}
if strings.TrimSpace(schemaContractString(tool["interface_reason"])) == "" {
t.Errorf("%s has no reviewed composite interface reason", canonical)
}
}
func assertEmbeddedShortcutParameters(
t testing.TB,
tool map[string]any,
declared shortcut.Shortcut,
canonical string,
) {
t.Helper()
parameters := schemaContractMap(tool["parameters"])
publicFlags := make([]shortcut.Flag, 0, len(declared.Flags))
for _, flag := range declared.Flags {
if !flag.Hidden {
publicFlags = append(publicFlags, flag)
}
}
if got, want := len(parameters), len(publicFlags); got != want {
t.Errorf("%s parameters = %d, want %d", canonical, got, want)
}
for _, flag := range publicFlags {
parameter := parameters[flag.Name]
if parameter == nil {
t.Errorf("%s is missing parameter --%s", canonical, flag.Name)
continue
}
flagType := flag.Type
if flagType == "" {
flagType = shortcut.FlagString
}
wantType := map[shortcut.FlagType]string{
shortcut.FlagString: "string",
shortcut.FlagBool: "boolean",
shortcut.FlagInt: "integer",
shortcut.FlagStringSlice: "array",
}[flagType]
if got := schemaContractString(parameter["type"]); got != wantType {
t.Errorf("%s --%s type = %q, want %q", canonical, flag.Name, got, wantType)
}
if got, _ := parameter["required"].(bool); got != shortcutSchemaRequired(declared, flag.Name) {
t.Errorf("%s --%s required = %t, want %t", canonical, flag.Name, got, shortcutSchemaRequired(declared, flag.Name))
}
if got, want := schemaContractString(parameter["default"]), shortcutSchemaDefault(flag); got != want {
t.Errorf("%s --%s default = %q, want %q", canonical, flag.Name, got, want)
}
gotEnum := schemaContractStringSlice(parameter["enum"])
if len(flag.Enum) == 0 {
if len(gotEnum) != 0 {
t.Errorf("%s --%s enum = %#v, want empty", canonical, flag.Name, gotEnum)
}
} else if !schemaContractJSONEqual(gotEnum, flag.Enum) {
t.Errorf("%s --%s enum = %#v, want %#v", canonical, flag.Name, gotEnum, flag.Enum)
}
}
}
func shortcutSchemaDefault(flag shortcut.Flag) string {
value := strings.TrimSpace(flag.Default)
switch flag.Type {
case shortcut.FlagBool:
if value != "true" {
return ""
}
case shortcut.FlagInt:
if value == "0" {
return ""
}
case shortcut.FlagStringSlice:
if value != "" {
return "[" + value + "]"
}
}
return value
}
func shortcutSchemaRequired(declared shortcut.Shortcut, flagName string) bool {
for _, flag := range declared.Flags {
if flag.Name == flagName && flag.Required {
return true
}
}
public := make(map[string]bool, len(declared.Flags))
for _, flag := range declared.Flags {
if !flag.Hidden {
public[flag.Name] = true
}
}
for _, constraint := range declared.Constraints {
if constraint.Kind != shortcut.ConstraintAtLeastOne && constraint.Kind != shortcut.ConstraintExactlyOne {
continue
}
visible := make([]string, 0, len(constraint.Flags))
for _, constrained := range constraint.Flags {
if public[constrained] {
visible = append(visible, constrained)
}
}
if len(visible) == 1 && visible[0] == flagName {
return true
}
}
return false
}
func assertEmbeddedShortcutConstraints(
t testing.TB,
tool map[string]any,
declared shortcut.Shortcut,
canonical string,
) {
t.Helper()
public := make(map[string]bool, len(declared.Flags))
for _, flag := range declared.Flags {
if !flag.Hidden {
public[flag.Name] = true
}
}
want := map[string][][]string{}
for _, constraint := range declared.Constraints {
flags := make([]string, 0, len(constraint.Flags))
for _, flagName := range constraint.Flags {
if public[flagName] {
flags = append(flags, flagName)
}
}
switch constraint.Kind {
case shortcut.ConstraintAtLeastOne:
if len(flags) > 1 {
want["require_one_of"] = append(want["require_one_of"], flags)
}
case shortcut.ConstraintExactlyOne:
if len(flags) > 1 {
want["require_one_of"] = append(want["require_one_of"], flags)
want["mutually_exclusive"] = append(want["mutually_exclusive"], flags)
}
case shortcut.ConstraintMutuallyExclusive:
if len(flags) > 1 {
want["mutually_exclusive"] = append(want["mutually_exclusive"], flags)
}
case shortcut.ConstraintCustom:
for _, flagName := range flags {
description := schemaContractString(schemaContractMap(tool["parameters"])[flagName]["description"])
for _, evidence := range shortcutCustomConstraintEvidence(constraint.Description) {
if !strings.Contains(description, evidence) {
t.Errorf("%s --%s description does not publish custom constraint evidence %q: %q", canonical, flagName, evidence, description)
}
}
}
default:
t.Errorf("%s has unsupported declared shortcut constraint %q", canonical, constraint.Kind)
}
}
if len(want) == 0 {
if got := tool["constraints"]; got != nil {
t.Errorf("%s constraints = %#v, want omitted", canonical, got)
}
return
}
if got := tool["constraints"]; !schemaContractJSONEqual(got, want) {
t.Errorf("%s constraints = %s, want %s", canonical, mustShortcutJSON(got), mustShortcutJSON(want))
}
}
func shortcutCustomConstraintEvidence(description string) []string {
// Custom constraints are prose rather than a typed wire contract. Require
// their decision-relevant facts to survive in the delivered parameter
// description while allowing the renderer to reorder connective wording.
probes := []string{
"原文=>替换",
"不能为空",
"不能重复",
"大于 0",
"工作目录",
"相对路径",
"绝对路径",
"..",
"最多 15 个字符",
}
evidence := make([]string, 0, len(probes))
for _, probe := range probes {
if strings.Contains(description, probe) {
evidence = append(evidence, probe)
}
}
if len(evidence) > 0 {
return evidence
}
return []string{strings.TrimSpace(description)}
}
func mustShortcutJSON(value any) string {
encoded, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("%#v", value)
}
return string(encoded)
}
+27
View File
@@ -66,6 +66,33 @@ func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName st
return convertResult(result), nil
}
type dryRunReadRunner interface {
RunReadOnly(context.Context, executor.Invocation) (executor.Result, error)
}
// CallReadTool executes a Shortcut's explicitly classified read lookup while
// the outer command is in dry-run mode. Ordinary CallTool remains protected by
// the global execution barrier. The runner capability is optional and fails
// closed so an injected runner cannot accidentally receive a real call.
func (a *toolCallerAdapter) CallReadTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
if a == nil || a.runner == nil {
return nil, fmt.Errorf("ToolCaller runner is not configured")
}
if !a.DryRun() {
return a.CallTool(ctx, productID, toolName, args)
}
readRunner, ok := a.runner.(dryRunReadRunner)
if !ok {
return nil, fmt.Errorf("ToolCaller runner does not support read-only dry-run lookups")
}
inv := executor.NewHelperInvocation("overlay."+productID+"."+toolName, productID, toolName, args)
result, err := readRunner.RunReadOnly(ctx, inv)
if err != nil {
return nil, err
}
return convertResult(result), nil
}
// CallToolWithToken invokes a helper with an in-memory token override. It is
// used during login before the new token has been persisted to any profile
// slot.
+13
View File
@@ -39,6 +39,19 @@ func (r recordingToolCaller) CallTool(ctx context.Context, product, tool string,
return res, err
}
func (r recordingToolCaller) CallReadTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
inner, ok := r.inner.(edition.ReadToolCaller)
if !ok {
return nil, fmt.Errorf("ToolCaller read-only dry-run lookup is not configured")
}
recordedArgs := cloneToolArgs(args)
res, err := inner.CallReadTool(ctx, product, tool, args)
// This is a real read even though the outer command is a dry-run. Record it
// as such so usage evidence does not misclassify the lookup as skipped.
usage.Append(product, tool, recordedArgs, err == nil, false)
return res, err
}
func (r recordingToolCaller) CallToolWithToken(
ctx context.Context,
token, product, tool string,
+56
View File
@@ -27,6 +27,7 @@ type crossPlatformCoverageCaller struct {
args map[string]any
token string
dryRun bool
reads int
}
func (c *crossPlatformCoverageCaller) CallTool(_ context.Context, _, _ string, args map[string]any) (*edition.ToolResult, error) {
@@ -44,6 +45,16 @@ func (c *crossPlatformCoverageCaller) CallToolWithToken(
return &edition.ToolResult{}, nil
}
func (c *crossPlatformCoverageCaller) CallReadTool(
_ context.Context,
_, _ string,
args map[string]any,
) (*edition.ToolResult, error) {
c.reads++
c.args = args
return &edition.ToolResult{}, nil
}
func (*crossPlatformCoverageCaller) Format() string { return "json" }
func (c *crossPlatformCoverageCaller) DryRun() bool { return c.dryRun }
func (*crossPlatformCoverageCaller) Fields() string { return "id,name" }
@@ -129,6 +140,51 @@ func TestCrossPlatformCoverageRecordingToolCaller(t *testing.T) {
}
}
func TestCrossPlatformCoverageRecordingReadToolCaller(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv("DWS_USAGE_TRACKING", "1")
inner := &crossPlatformCoverageCaller{dryRun: true}
caller := newRecordingToolCaller(inner)
readCaller, ok := caller.(edition.ReadToolCaller)
if !ok {
t.Fatal("recording caller dropped read-only capability")
}
if _, err := readCaller.CallReadTool(
context.Background(),
"im",
"search_groups",
map[string]any{"open_conversation_id": "cid_x"},
); err != nil {
t.Fatal(err)
}
if inner.reads != 1 || inner.args["open_conversation_id"] != "cid_x" {
t.Fatalf("read forwarding = reads %d args %#v", inner.reads, inner.args)
}
records, err := usage.Read()
if err != nil {
t.Fatal(err)
}
if len(records) != 1 || !records[0].OK {
t.Fatalf("real read must be recorded as successful: %#v", records)
}
withoutRead := recordingToolCaller{inner: nonReadToolCaller{}}
if _, err := withoutRead.CallReadTool(context.Background(), "im", "search_groups", nil); err == nil {
t.Fatal("recording caller accepted an inner caller without read support")
}
}
type nonReadToolCaller struct{}
func (nonReadToolCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return &edition.ToolResult{}, nil
}
func (nonReadToolCaller) Format() string { return "json" }
func (nonReadToolCaller) DryRun() bool { return false }
func (nonReadToolCaller) Fields() string { return "" }
func (nonReadToolCaller) JQ() string { return "" }
func TestCrossPlatformCoverageRootPublishesShortcutCommands(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
+89 -8
View File
@@ -3,6 +3,10 @@ package auth
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
"testing"
"time"
)
@@ -22,15 +26,92 @@ func TestCrossPlatformCoverageOAuthProviderTokenSnapshotPreservesLoadFailure(t *
}
}
func TestCrossPlatformCoverageOAuthProviderLoginPreservesLoadFailure(t *testing.T) {
oldLoad := oauthLoadToken
want := errors.New("keychain permission denied")
oauthLoadToken = func(string) (*TokenData, error) { return nil, want }
t.Cleanup(func() { oauthLoadToken = oldLoad })
func TestCrossPlatformCoverageOAuthProviderLoginReauthorizesAfterLoadFailureAndRejectsUnreadableTarget(t *testing.T) {
cleanupKeychain(t)
setLoginPreflightCredentials(t)
_, err := NewOAuthProvider(t.TempDir(), nil).Login(context.Background(), false)
if !errors.Is(err, want) {
t.Fatalf("error = %v, want cause %v", err, want)
oldLoad := oauthLoadToken
oldOpenBrowser := oauthOpenBrowser
oldExchange := oauthExchange
oldCheckStatus := oauthCheckStatus
oldSave := oauthSaveToken
oldKeychainGet := authKeychainGet
oldLoginTimeout := oauthLoginTimeout
t.Cleanup(func() {
oauthLoadToken = oldLoad
oauthOpenBrowser = oldOpenBrowser
oauthExchange = oldExchange
oauthCheckStatus = oldCheckStatus
oauthSaveToken = oldSave
authKeychainGet = oldKeychainGet
oauthLoginTimeout = oldLoginTimeout
})
oauthLoginTimeout = 2 * time.Second
loadErr := errors.New("keychain permission denied")
targetErr := errors.New("target token ciphertext is unreadable")
oauthLoadToken = func(string) (*TokenData, error) { return nil, loadErr }
browserCalls := 0
oauthOpenBrowser = func(authURL string) error {
browserCalls++
parsed, err := url.Parse(authURL)
if err != nil {
return err
}
callbackURL := parsed.Query().Get("redirect_uri") + "?code=reauthorize"
response, err := (&http.Client{Timeout: 5 * time.Second}).Get(callbackURL)
if err != nil {
return err
}
_, _ = io.Copy(io.Discard, response.Body)
return response.Body.Close()
}
exchangeCalls := 0
oauthExchange = func(*OAuthProvider, context.Context, string) (*TokenData, error) {
exchangeCalls++
return &TokenData{
AccessToken: "new-access",
CorpID: "corp-target",
UserID: "user-target",
}, nil
}
oauthCheckStatus = func(*OAuthProvider, context.Context, string) (*CLIAuthStatus, error) {
return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}, nil
}
targetReads := 0
authKeychainGet = func(_ string, account string) (string, error) {
if account == TokenAccountForIdentity("corp-target", "user-target") {
targetReads++
return "", targetErr
}
return "", nil
}
saveCalls := 0
oauthSaveToken = func(string, *TokenData) error {
saveCalls++
return nil
}
provider := NewOAuthProvider(t.TempDir(), slog.New(slog.NewTextHandler(io.Discard, nil)))
provider.Output = io.Discard
_, err := provider.Login(context.Background(), false)
if !errors.Is(err, targetErr) {
t.Fatalf("Login() error = %v, want target cause %v", err, targetErr)
}
if errors.Is(err, loadErr) {
t.Fatalf("Login() returned stale load failure instead of reauthorizing: %v", err)
}
if browserCalls != 1 || exchangeCalls != 1 {
t.Fatalf("authorization calls = browser:%d exchange:%d, want 1 each", browserCalls, exchangeCalls)
}
if targetReads != 1 {
t.Fatalf("target slot reads = %d, want 1", targetReads)
}
if saveCalls != 0 {
t.Fatalf("SaveTokenData calls = %d, want 0", saveCalls)
}
}
@@ -0,0 +1,297 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"errors"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
// The native coverage jobs intentionally execute only TestCrossPlatformCoverage
// entry points. Keep the compatibility assertions below as independently named
// regression tests for the stable make target, and exercise the same functions
// here as isolated subtests so their cleanup hooks run between cases.
func TestCrossPlatformCoverageAuthLegacyCompatibilityRegressions(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
tests := []struct {
name string
run func(*testing.T)
}{
{"prepare unique global owner", TestPrepareLoginPersistenceRepairsOnlySafeGlobalOwner},
{"token load isolation matrix", TestTokenLoadIsolationMatrix},
{"reject future profiles before remote work", TestPersistingLoginFlowsRejectFutureProfilesBeforeRemoteWork},
{"fresh UID-less isolation", TestFreshUIDLessExactLoginCannotOverwriteExistingUnresolvedProfile},
{"reject mismatched exact switch", TestSetCurrentProfileRejectsMismatchedExactIdentitySlotBesideBlankProfile},
{"reject unreadable previous identity", TestUsePreviousProfileRejectsUnreadableExactIdentityBesideBlankProfile},
{"reauthorization guidance without profile", TestLegacyRefreshReauthorizationGuidanceWithoutProfileStillExplainsLogin},
{"repair v3 unresolved profile", TestPrepareLoginPersistenceV3UnresolvedProfileRepair},
{"device ignores unrelated unreadable profile", TestDeviceLoginIgnoresUnreadableUnrelatedProfile},
{"reject unreadable global before login", TestPrepareLoginPersistenceUnreadableGlobalFailsClosedBeforeRemote},
{"device validates resolved target", TestDeviceFlowChecksResolvedTargetBeforeSave},
{"accept recoverable credential material", TestPrepareLoginPersistenceRequiresCredentialMaterialButNotValidity},
{"auth code validates resolved target", TestExchangeAuthCodeChecksResolvedTargetBeforeSave},
{"standalone exchange prepares and marks fresh", TestExchangeCodeForTokenPreparesBeforeRemoteAndMarksFresh},
}
for _, test := range tests {
t.Run(test.name, test.run)
}
}
func TestCrossPlatformCoverageHalfMigratedGlobalRepairRemainingEdges(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
t.Run("nil global token", func(t *testing.T) {
isolateHalfMigratedRepairHooks(t)
profilesLoadLegacy = func() (*TokenData, error) { return nil, nil }
cfg := &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{CorpID: "corp_nil_global"}}}
if err := repairHalfMigratedGlobalTokenLocked(cfg); err != nil {
t.Fatalf("repairHalfMigratedGlobalTokenLocked() error = %v", err)
}
})
t.Run("global token without organization", func(t *testing.T) {
isolateHalfMigratedRepairHooks(t)
profilesLoadLegacy = func() (*TokenData, error) {
return &TokenData{AccessToken: "legacy"}, nil
}
cfg := &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{CorpID: "corp_other"}}}
if err := repairHalfMigratedGlobalTokenLocked(cfg); err != nil {
t.Fatalf("repairHalfMigratedGlobalTokenLocked() error = %v", err)
}
})
t.Run("orphan global organization", func(t *testing.T) {
isolateHalfMigratedRepairHooks(t)
profilesLoadLegacy = func() (*TokenData, error) {
return &TokenData{AccessToken: "legacy", CorpID: "corp_orphan"}, nil
}
cfg := &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{CorpID: "corp_other"}}}
if err := repairHalfMigratedGlobalTokenLocked(cfg); err != nil {
t.Fatalf("repairHalfMigratedGlobalTokenLocked() error = %v", err)
}
})
t.Run("identity repair write failure", func(t *testing.T) {
isolateHalfMigratedRepairHooks(t)
failure := errors.New("identity repair write failure")
const corpID, userID = "corp_identity_repair", "user_identity_repair"
profilesLoadLegacy = func() (*TokenData, error) {
return &TokenData{AccessToken: "legacy", CorpID: corpID}, nil
}
profilesLoadCorp = func(string) (*TokenData, error) {
return &TokenData{AccessToken: "organization", CorpID: corpID}, nil
}
profilesSaveIdentity = func(string, string, *TokenData) error { return failure }
cfg := &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{CorpID: corpID, UserID: userID}}}
if err := repairHalfMigratedGlobalTokenLocked(cfg); !errors.Is(err, failure) {
t.Fatalf("repairHalfMigratedGlobalTokenLocked() error = %v, want %v", err, failure)
}
})
t.Run("organization repair write failure", func(t *testing.T) {
isolateHalfMigratedRepairHooks(t)
failure := errors.New("organization repair write failure")
const corpID = "corp_organization_repair"
profilesLoadLegacy = func() (*TokenData, error) {
return &TokenData{AccessToken: "legacy", CorpID: corpID}, nil
}
profilesSaveCorp = func(string, *TokenData) error { return failure }
cfg := &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{CorpID: corpID}}}
if err := repairHalfMigratedGlobalTokenLocked(cfg); !errors.Is(err, failure) {
t.Fatalf("repairHalfMigratedGlobalTokenLocked() error = %v, want %v", err, failure)
}
})
t.Run("nil profile is not canonical", func(t *testing.T) {
if loginProfileHasUsableCanonicalToken(nil, nil, nil) {
t.Fatal("nil profile was treated as a usable canonical token")
}
})
t.Run("global write preflight reports unreadable slot", func(t *testing.T) {
oldGet := authKeychainGet
failure := errors.New("global ciphertext is unreadable")
authKeychainGet = func(_, account string) (string, error) {
if account == keychain.AccountToken {
return "", failure
}
return "", nil
}
t.Cleanup(func() { authKeychainGet = oldGet })
err := preflightTokenWritePersistence(t.TempDir(), &TokenData{AccessToken: "fresh"})
if !errors.Is(err, failure) {
t.Fatalf("preflightTokenWritePersistence() error = %v, want %v", err, failure)
}
})
}
func TestCrossPlatformCoverageLegacyProfileGuardRemainingEdges(t *testing.T) {
t.Run("empty v3 registry normalizes and persists", func(t *testing.T) {
oldLoad := profilesLoad
oldSave := profilesSave
cfg := &ProfilesConfig{Version: profilesUnresolvedSelectorVersion}
profilesLoad = func(string) (*ProfilesConfig, error) { return cfg, nil }
saves := 0
profilesSave = func(string, *ProfilesConfig) error {
saves++
return nil
}
t.Cleanup(func() {
profilesLoad = oldLoad
profilesSave = oldSave
})
if err := ensureProfilesMigrationLocked(t.TempDir()); err != nil {
t.Fatalf("ensureProfilesMigrationLocked() error = %v", err)
}
if cfg.Version != profilesVersion || saves != 1 {
t.Fatalf("normalized registry = version %d saves %d", cfg.Version, saves)
}
})
if normalizeProfilesVersionForSelectors(nil) {
t.Fatal("nil profile registry reported a version change")
}
future := &ProfilesConfig{Version: profilesMaxVersion + 1}
if normalizeProfilesVersionForSelectors(future) {
t.Fatal("future profile registry reported a version change")
}
if profilesConfigContainsUnresolvedSelector(nil) {
t.Fatal("nil profile registry contained an unresolved selector")
}
reserved := unresolvedProfileSelector("corp_org_current_guard")
if !profilesConfigContainsUnresolvedSelector(&ProfilesConfig{
OrgCurrentProfiles: map[string]string{"corp_org_current_guard": reserved},
}) {
t.Fatal("reserved organization-current selector was not detected")
}
if selectorConflictsWithOrganizationGrammar(nil, "corp") {
t.Fatal("nil profile registry reported an organization-selector conflict")
}
if err := validateIdentityOnlyProfileToken(Profile{}); !errors.Is(err, ErrTokenDataNotFound) {
t.Fatalf("validateIdentityOnlyProfileToken(blank) error = %v", err)
}
oldLoadIdentity := profilesLoadIdentity
profilesLoadIdentity = func(string, string) (*TokenData, error) { return nil, nil }
t.Cleanup(func() { profilesLoadIdentity = oldLoadIdentity })
if err := validateIdentityOnlyProfileToken(Profile{CorpID: "corp_nil_identity", UserID: "user_nil_identity"}); !errors.Is(err, ErrTokenDataNotFound) {
t.Fatalf("validateIdentityOnlyProfileToken(nil token) error = %v", err)
}
}
func TestCrossPlatformCoverageTokenPersistenceRemainingEdges(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
plan := planTokenPersistenceWrites(&ProfilesConfig{}, nil, "")
if !plan.WriteGlobal || plan.CorpID != "" {
t.Fatalf("nil-token write plan = %#v", plan)
}
if err := SaveLoginTokenData(t.TempDir(), nil); err == nil {
t.Fatal("SaveLoginTokenData(nil) succeeded")
}
futureDir := t.TempDir()
writeFutureProfilesForLoginPreflight(t, futureDir)
err := SaveLoginTokenData(futureDir, &TokenData{AccessToken: "fresh"})
if err == nil || !strings.Contains(err.Error(), "newer than supported") {
t.Fatalf("SaveLoginTokenData(future schema) error = %v", err)
}
t.Run("nil identity token", func(t *testing.T) {
isolateTokenProfileLoadHooks(t)
tokenLoadKeychainIdentity = func(string, string) (*TokenData, error) { return nil, nil }
_, err := tokenLoadProfileIdentity(Profile{CorpID: "corp_nil_identity", UserID: "user_nil_identity"})
if !errors.Is(err, ErrTokenDataNotFound) {
t.Fatalf("tokenLoadProfileIdentity() error = %v", err)
}
})
t.Run("nil organization fallback", func(t *testing.T) {
isolateTokenProfileLoadHooks(t)
tokenLoadKeychainForCorpID = func(string) (*TokenData, error) { return nil, nil }
_, err := tokenLoadProfileIdentity(Profile{CorpID: "corp_nil_org", UserID: "user_nil_org"})
if !errors.Is(err, ErrTokenDataNotFound) {
t.Fatalf("tokenLoadProfileIdentity() error = %v", err)
}
})
t.Run("organization fallback belongs to another organization", func(t *testing.T) {
isolateTokenProfileLoadHooks(t)
tokenLoadKeychainForCorpID = func(string) (*TokenData, error) {
return &TokenData{AccessToken: "wrong", CorpID: "corp_other", UserID: "user_wrong_org"}, nil
}
_, err := tokenLoadProfileIdentity(Profile{CorpID: "corp_expected", UserID: "user_wrong_org"})
if err == nil || !strings.Contains(err.Error(), "contains token for corpId") {
t.Fatalf("tokenLoadProfileIdentity() error = %v", err)
}
})
t.Run("identity repair write failure", func(t *testing.T) {
isolateTokenProfileLoadHooks(t)
failure := errors.New("identity repair write failure")
const corpID, userID = "corp_identity_save", "user_identity_save"
tokenLoadKeychainForCorpID = func(string) (*TokenData, error) {
return &TokenData{AccessToken: "organization", CorpID: corpID, UserID: userID}, nil
}
tokenSaveKeychainForIdentity = func(string, string, *TokenData) error { return failure }
_, err := tokenLoadProfileIdentity(Profile{CorpID: corpID, UserID: userID})
if !errors.Is(err, failure) {
t.Fatalf("tokenLoadProfileIdentity() error = %v, want %v", err, failure)
}
})
}
func isolateHalfMigratedRepairHooks(t *testing.T) {
t.Helper()
oldLoadLegacy := profilesLoadLegacy
oldLoadCorp := profilesLoadCorp
oldLoadIdentity := profilesLoadIdentity
oldSaveCorp := profilesSaveCorp
oldSaveIdentity := profilesSaveIdentity
t.Cleanup(func() {
profilesLoadLegacy = oldLoadLegacy
profilesLoadCorp = oldLoadCorp
profilesLoadIdentity = oldLoadIdentity
profilesSaveCorp = oldSaveCorp
profilesSaveIdentity = oldSaveIdentity
})
profilesLoadLegacy = func() (*TokenData, error) { return nil, ErrTokenDataNotFound }
profilesLoadCorp = func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
profilesLoadIdentity = func(string, string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
profilesSaveCorp = func(string, *TokenData) error { return nil }
profilesSaveIdentity = func(string, string, *TokenData) error { return nil }
}
func isolateTokenProfileLoadHooks(t *testing.T) {
t.Helper()
oldLoadIdentity := tokenLoadKeychainIdentity
oldLoadCorp := tokenLoadKeychainForCorpID
oldSaveIdentity := tokenSaveKeychainForIdentity
t.Cleanup(func() {
tokenLoadKeychainIdentity = oldLoadIdentity
tokenLoadKeychainForCorpID = oldLoadCorp
tokenSaveKeychainForIdentity = oldSaveIdentity
})
tokenLoadKeychainIdentity = func(string, string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
tokenLoadKeychainForCorpID = func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
tokenSaveKeychainForIdentity = func(string, string, *TokenData) error { return nil }
}
@@ -0,0 +1,357 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"encoding/json"
"os"
"strings"
"testing"
)
type blankProfileSelectorFixture struct {
configDir string
corpID string
blankName string
blankSelector string
exactUserID string
exactSelector string
blankToken *TokenData
exactToken *TokenData
}
func seedBlankProfileSelectorFixture(
t *testing.T,
blankName string,
corpName string,
blankCurrent bool,
) blankProfileSelectorFixture {
t.Helper()
cleanupKeychain(t)
configDir := t.TempDir()
corpID := "corp_selector_fixture"
exactUserID := "identity_exact_fixture"
exactSelector := profileSelector(corpID, exactUserID)
blankToken := testToken("at_unresolved_fixture", corpID, corpName)
blankToken.UserID = ""
blankToken.UserName = ""
exactToken := testToken("at_exact_fixture", corpID, corpName)
exactToken.UserID = exactUserID
exactToken.UserName = "Exact Fixture Account"
if err := SaveTokenDataKeychainForCorpID(corpID, blankToken); err != nil {
t.Fatalf("SaveTokenDataKeychainForCorpID(blank) error = %v", err)
}
if err := SaveTokenDataKeychainForIdentity(corpID, exactUserID, exactToken); err != nil {
t.Fatalf("SaveTokenDataKeychainForIdentity(exact) error = %v", err)
}
if err := SaveTokenDataKeychain(exactToken); err != nil {
t.Fatalf("SaveTokenDataKeychain(exact mirror) error = %v", err)
}
if err := WriteTokenMarker(configDir); err != nil {
t.Fatalf("WriteTokenMarker() error = %v", err)
}
cfg := &ProfilesConfig{
Version: profilesVersion,
PrimaryProfile: exactSelector,
OrgCurrentProfiles: map[string]string{
corpID: exactSelector,
},
Profiles: []Profile{
{
Name: blankName,
CorpID: corpID,
CorpName: corpName,
Status: ProfileStatusActive,
},
{
Name: "Exact Fixture Account",
CorpID: corpID,
CorpName: corpName,
UserID: exactUserID,
UserName: "Exact Fixture Account",
Status: ProfileStatusActive,
},
},
}
blankSelector := ProfileSelectionSelector(cfg.Profiles[0], cfg)
persistedBlankPointer := strings.TrimSpace(blankName)
if selectorConflictsWithOrganizationGrammar(cfg, persistedBlankPointer) {
// An ambiguous local name has always been captured by CorpId/CorpName
// grammar in the public resolver. New writers must use the reserved
// selector to preserve exact blank-profile intent without changing that
// precedence.
persistedBlankPointer = blankSelector
if _, reserved := parseUnresolvedProfileSelector(persistedBlankPointer); reserved {
cfg.Version = profilesUnresolvedSelectorVersion
}
}
cfg.CurrentProfile = exactSelector
cfg.PreviousProfile = persistedBlankPointer
if blankCurrent {
cfg.CurrentProfile = persistedBlankPointer
cfg.PreviousProfile = exactSelector
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
t.Fatalf("json.MarshalIndent(profiles) error = %v", err)
}
data = append(data, '\n')
if err := os.WriteFile(ProfilesPath(configDir), data, 0o600); err != nil {
t.Fatalf("os.WriteFile(profiles.json) error = %v", err)
}
return blankProfileSelectorFixture{
configDir: configDir,
corpID: corpID,
blankName: blankName,
blankSelector: blankSelector,
exactUserID: exactUserID,
exactSelector: exactSelector,
blankToken: blankToken,
exactToken: exactToken,
}
}
func TestCrossPlatformCoverageBlankProfileNameMatchingCorpNameResolvesCurrentProfileExactly(t *testing.T) {
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
selected, exact, err := ResolveProfileWithScope(fixture.configDir, "")
if err != nil {
t.Fatalf("ResolveProfileWithScope(current) error = %v", err)
}
if selected == nil || selected.CorpID != fixture.corpID || selected.UserID != "" {
t.Fatalf("resolved current profile = %#v, want unresolved profile", selected)
}
if !exact {
t.Fatal("current local-name selector should resolve one exact unresolved profile")
}
cfg, err := LoadProfiles(fixture.configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != fixture.blankSelector {
t.Fatalf("current profile = %q, want stable unresolved selector %q", cfg.CurrentProfile, fixture.blankSelector)
}
}
func TestCrossPlatformCoverageBlankProfileNameMatchingCorpNameLoadsOrganizationToken(t *testing.T) {
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", false)
if fixture.blankSelector == fixture.blankName || fixture.blankSelector == fixture.corpID {
t.Fatalf("unsafe blank selector = %q, want reserved exact selector", fixture.blankSelector)
}
loaded, err := LoadTokenDataForProfile(fixture.configDir, fixture.blankSelector)
if err != nil {
t.Fatalf("LoadTokenDataForProfile(blank local name) error = %v", err)
}
if loaded.UserID != "" || loaded.AccessToken != fixture.blankToken.AccessToken {
t.Fatalf("loaded token = %#v, want unresolved organization token", loaded)
}
}
func TestCrossPlatformCoverageBlankProfileNameMatchingCorpNameRoundTripsPreviousProfile(t *testing.T) {
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", false)
selected, err := UsePreviousProfile(fixture.configDir)
if err != nil {
t.Fatalf("UsePreviousProfile() error = %v", err)
}
if selected == nil || selected.CorpID != fixture.corpID || selected.UserID != "" {
t.Fatalf("selected previous profile = %#v, want unresolved profile", selected)
}
cfg, err := LoadProfiles(fixture.configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != fixture.blankSelector || cfg.PreviousProfile != fixture.exactSelector {
t.Fatalf(
"profile pointers = current %q previous %q, want %q and %q",
cfg.CurrentProfile,
cfg.PreviousProfile,
fixture.blankSelector,
fixture.exactSelector,
)
}
}
func TestCrossPlatformCoverageBlankProfileNameMatchingCorpNameDeletesOnlyUnresolvedProfile(t *testing.T) {
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", false)
if err := DeleteTokenDataForProfile(fixture.configDir, fixture.blankSelector); err != nil {
t.Fatalf("DeleteTokenDataForProfile(blank local name) error = %v", err)
}
cfg, err := LoadProfiles(fixture.configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != fixture.corpID || cfg.Profiles[0].UserID != fixture.exactUserID {
t.Fatalf("profiles after blank deletion = %#v, want only exact account", cfg.Profiles)
}
loaded, err := LoadTokenDataForProfile(fixture.configDir, fixture.exactSelector)
if err != nil {
t.Fatalf("LoadTokenDataForProfile(exact after blank deletion) error = %v", err)
}
if loaded.UserID != fixture.exactUserID || loaded.AccessToken != fixture.exactToken.AccessToken {
t.Fatalf("exact token after blank deletion = %#v, want exact account preserved", loaded)
}
}
func TestCrossPlatformCoverageBlankProfileNameContainingColonWinsOverIdentitySyntax(t *testing.T) {
fixture := seedBlankProfileSelectorFixture(t, "legacy:outsourced", "Fixture Organization", true)
if fixture.blankSelector == fixture.blankName {
t.Fatalf("colon-containing name leaked as selector %q", fixture.blankSelector)
}
if _, _, parsedAsIdentity := ParseIdentitySelector(fixture.blankSelector); parsedAsIdentity {
t.Fatalf("stable blank selector %q was parsed as an identity", fixture.blankSelector)
}
selected, exact, err := ResolveProfileWithScope(fixture.configDir, "")
if err != nil {
t.Fatalf("ResolveProfileWithScope(colon local name) error = %v", err)
}
if selected == nil || selected.CorpID != fixture.corpID || selected.UserID != "" {
t.Fatalf("resolved colon-name profile = %#v, want unresolved profile", selected)
}
if !exact {
t.Fatal("colon-containing local name should resolve one exact unresolved profile")
}
cfg, err := LoadProfiles(fixture.configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != fixture.blankSelector {
t.Fatalf("current profile = %q, want migrated colon-name selector %q", cfg.CurrentProfile, fixture.blankSelector)
}
loaded, err := LoadTokenDataForProfile(fixture.configDir, fixture.blankSelector)
if err != nil {
t.Fatalf("LoadTokenDataForProfile(colon local name) error = %v", err)
}
if loaded.UserID != "" || loaded.AccessToken != fixture.blankToken.AccessToken {
t.Fatalf("loaded colon-name token = %#v, want unresolved organization token", loaded)
}
}
func TestCrossPlatformCoverageRealExactSelectorWinsOverMatchingBlankLegacyName(t *testing.T) {
const exactSelector = "corp_selector_fixture:identity_exact_fixture"
fixture := seedBlankProfileSelectorFixture(t, exactSelector, "Fixture Organization", true)
selected, exact, err := ResolveProfileWithScope(fixture.configDir, "")
if err != nil {
t.Fatalf("ResolveProfileWithScope(current exact collision) error = %v", err)
}
if selected == nil || selected.UserID != fixture.exactUserID || !exact {
t.Fatalf("resolved current collision = %#v exact=%v, want real exact identity", selected, exact)
}
cfg, err := LoadProfiles(fixture.configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != exactSelector {
t.Fatalf("current collision selector = %q, want real exact %q", cfg.CurrentProfile, exactSelector)
}
blank, err := LoadTokenDataForProfile(fixture.configDir, fixture.blankSelector)
if err != nil {
t.Fatalf("LoadTokenDataForProfile(reserved blank collision) error = %v", err)
}
if blank.UserID != "" || blank.AccessToken != fixture.blankToken.AccessToken {
t.Fatalf("reserved blank collision token = %#v", blank)
}
if err := DeleteTokenDataForProfile(fixture.configDir, fixture.blankSelector); err != nil {
t.Fatalf("DeleteTokenDataForProfile(reserved blank collision) error = %v", err)
}
exactToken, err := LoadTokenDataForProfile(fixture.configDir, exactSelector)
if err != nil || exactToken.UserID != fixture.exactUserID {
t.Fatalf("exact identity after blank collision delete = %#v, %v", exactToken, err)
}
}
func TestCrossPlatformCoverageUnrelatedProfileDeletionPreservesBlankOrganizationCurrentMapping(t *testing.T) {
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
cfg, err := LoadProfiles(fixture.configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
cfg.Profiles = append(cfg.Profiles, Profile{
Name: "Unrelated Account",
CorpID: "corp_unrelated_fixture",
UserID: "identity_unrelated_fixture",
Status: ProfileStatusActive,
})
if err := SaveProfiles(fixture.configDir, cfg); err != nil {
t.Fatalf("SaveProfiles(unrelated) error = %v", err)
}
if _, err := RemoveProfile(fixture.configDir, "corp_unrelated_fixture:identity_unrelated_fixture"); err != nil {
t.Fatalf("RemoveProfile(unrelated) error = %v", err)
}
cfg, err = LoadProfiles(fixture.configDir)
if err != nil {
t.Fatalf("LoadProfiles(after unrelated delete) error = %v", err)
}
if cfg.CurrentProfile != fixture.blankSelector {
t.Fatalf("blank current after unrelated delete = %q, want %q", cfg.CurrentProfile, fixture.blankSelector)
}
if got := cfg.OrgCurrentProfiles[fixture.corpID]; got != fixture.exactSelector {
t.Fatalf("organization current after unrelated delete = %q, want %q", got, fixture.exactSelector)
}
selected, err := ResolveProfile(fixture.configDir, fixture.corpID)
if err != nil || selected.UserID != fixture.exactUserID {
t.Fatalf("organization selector after unrelated delete = %#v, %v", selected, err)
}
}
func TestCrossPlatformCoverageSameCorpNonCurrentDeletionPreservesExactOrganizationCurrent(t *testing.T) {
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
cfg, err := LoadProfiles(fixture.configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
cfg.Profiles = append(cfg.Profiles, Profile{
Name: "Another Exact Account",
CorpID: fixture.corpID,
CorpName: "Fixture Organization",
UserID: "identity_noncurrent_fixture",
Status: ProfileStatusActive,
})
if err := SaveProfiles(fixture.configDir, cfg); err != nil {
t.Fatalf("SaveProfiles(non-current exact) error = %v", err)
}
if _, err := RemoveProfile(fixture.configDir, fixture.corpID+":identity_noncurrent_fixture"); err != nil {
t.Fatalf("RemoveProfile(non-current exact) error = %v", err)
}
cfg, err = LoadProfiles(fixture.configDir)
if err != nil {
t.Fatalf("LoadProfiles(after same-corp delete) error = %v", err)
}
if cfg.CurrentProfile != fixture.blankSelector {
t.Fatalf("blank current after same-corp delete = %q, want %q", cfg.CurrentProfile, fixture.blankSelector)
}
if got := cfg.OrgCurrentProfiles[fixture.corpID]; got != fixture.exactSelector {
t.Fatalf("organization current after same-corp delete = %q, want %q", got, fixture.exactSelector)
}
selected, err := ResolveProfile(fixture.configDir, fixture.corpID)
if err != nil || selected.UserID != fixture.exactUserID {
t.Fatalf("organization selector after same-corp delete = %#v, %v", selected, err)
}
}
+8 -2
View File
@@ -3159,7 +3159,13 @@ func TestCrossPlatformCoverageMultiAccountSelectorAndIdentityLoadEdges(t *testin
if got, err := loadTokenForProfileIdentity(profile); err != nil || got.AccessToken != "mirror" {
t.Fatalf("identity repair = %#v %v", got, err)
}
if _, err := loadTokenForProfileIdentity(Profile{CorpID: "corp-a"}); err != nil {
t.Fatalf("organization-only token load = %v", err)
if _, err := loadTokenForProfileIdentity(Profile{CorpID: "corp-a"}); err == nil {
t.Fatal("unresolved profile loaded organization token owned by an exact identity")
}
profilesLoadCorp = func(string) (*TokenData, error) {
return &TokenData{CorpID: "corp-a", AccessToken: "organization"}, nil
}
if got, err := loadTokenForProfileIdentity(Profile{CorpID: "corp-a"}); err != nil || got.AccessToken != "organization" {
t.Fatalf("organization-only token load = %#v %v", got, err)
}
}
+4 -1
View File
@@ -195,7 +195,7 @@ func (p *DeviceFlowProvider) resetCredentialState() {
}
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
if err := preflightTokenPersistence(p.configDir); err != nil {
if err := prepareLoginPersistence(p.configDir); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
}
@@ -357,6 +357,9 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
if err := oauthProvider.prepareLoginToken(ctx, tokenData); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
}
if err := preflightTokenWritePersistence(p.configDir, tokenData); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
}
if err := deviceSaveToken(p.configDir, tokenData); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
}
File diff suppressed because it is too large Load Diff
+207 -27
View File
@@ -138,11 +138,177 @@ func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
return &data, nil
}
// preflightTokenPersistence verifies that every registered token slot can be
// read before an OAuth login or exchange can target any profile.
// A missing slot is safe (first login or a legacy fallback); any other error
// stops the remote operation when existing ciphertext is already known to be
// unreadable and therefore unsafe to update.
// prepareLoginPersistence rejects profile registries written by a newer client
// and protects the legacy global mirror before a new authorization flow
// performs remote work. Version-1 registries keep using the existing full
// migration in saveTokenDataLocked before any compatibility mirror is
// overwritten.
//
// For v2/v3, only the organization referenced by the readable global mirror is
// inspected. A uniquely matching half-migrated profile is repaired from that
// mirror under the profiles lock. Missing or damaged slots in unrelated
// organizations and orphan inventory are deliberately not scanned.
func prepareLoginPersistence(configDir string) error {
if h := edition.Get(); h.SaveToken != nil {
return nil
}
return withProfilesLock(configDir, func() error {
cfg, err := profilesLoad(configDir)
if err != nil {
return fmt.Errorf("load token profiles: %w", err)
}
if err := ensureProfilesWritable(cfg); err != nil {
return err
}
return repairHalfMigratedGlobalTokenLocked(cfg)
})
}
// repairHalfMigratedGlobalTokenLocked preserves the only readable copy left by
// an interrupted v1.0.53 migration. The caller must hold the profiles lock.
func repairHalfMigratedGlobalTokenLocked(cfg *ProfilesConfig) error {
if cfg == nil || cfg.Version < profilesVersion || len(cfg.Profiles) == 0 {
return nil
}
global, err := profilesLoadLegacy()
if errors.Is(err, ErrTokenDataNotFound) {
return nil
}
if err != nil {
return fmt.Errorf(
"legacy token slot %q is unreadable; refusing to overwrite a potentially unique old login: %w",
keychain.AccountToken,
err,
)
}
if global == nil {
return nil
}
corpID := strings.TrimSpace(global.CorpID)
if corpID == "" {
return nil
}
profiles := profilesForCorpID(cfg, corpID)
if len(profiles) == 0 {
// A readable global token for an unregistered organization is an orphan,
// not a profile credential that this registry still promises to retain.
return nil
}
orgToken, orgErr := profilesLoadCorp(corpID)
allCanonical := true
for _, profile := range profiles {
if !loginProfileHasUsableCanonicalToken(profile, orgToken, orgErr) {
allCanonical = false
break
}
}
if allCanonical {
return nil
}
profile := uniqueV2GlobalRepairProfile(cfg, corpID)
if profile == nil {
return fmt.Errorf(
"legacy token slot %q may be the only recoverable login for one of %d accounts in organization %q; refusing to overwrite it until each account has a usable identity slot",
keychain.AccountToken,
len(profiles),
corpID,
)
}
userID := strings.TrimSpace(profile.UserID)
if userID != "" &&
orgErr == nil &&
loginTokenHasCredentialMaterial(orgToken) &&
legacyTokenMatchesV2RepairProfile(orgToken, profile) {
if err := repairLoginIdentityToken(profile, orgToken); err != nil {
return err
}
return nil
}
if !legacyTokenMatchesV2RepairProfile(global, profile) {
return fmt.Errorf(
"legacy token slot %q does not safely match the only profile in organization %q; refusing to overwrite a potentially unique old login",
keychain.AccountToken,
corpID,
)
}
if !loginTokenHasCredentialMaterial(global) {
return fmt.Errorf(
"legacy token slot %q has no recoverable credential material for organization %q; refusing to overwrite a potentially unique old login",
keychain.AccountToken,
corpID,
)
}
// The matching global token is the only recoverable copy. Overwrite a
// damaged organization slot as well as filling a missing one.
if err := profilesSaveCorp(corpID, global); err != nil {
return fmt.Errorf("repair organization token slot %q: %w", TokenAccountForCorpID(corpID), err)
}
if userID == "" {
return nil
}
return repairLoginIdentityToken(profile, global)
}
func loginProfileHasUsableCanonicalToken(
profile *Profile,
orgToken *TokenData,
orgErr error,
) bool {
if profile == nil {
return false
}
corpID := strings.TrimSpace(profile.CorpID)
userID := strings.TrimSpace(profile.UserID)
if userID == "" {
return orgErr == nil &&
loginTokenHasCredentialMaterial(orgToken) &&
strings.TrimSpace(orgToken.CorpID) == corpID &&
strings.TrimSpace(orgToken.UserID) == ""
}
identity, err := profilesLoadIdentity(corpID, userID)
if err == nil &&
loginTokenHasCredentialMaterial(identity) &&
strings.TrimSpace(identity.CorpID) == corpID &&
strings.TrimSpace(identity.UserID) == userID {
return true
}
return false
}
func loginTokenHasCredentialMaterial(data *TokenData) bool {
return data != nil &&
(strings.TrimSpace(data.AccessToken) != "" ||
strings.TrimSpace(data.RefreshToken) != "" ||
strings.TrimSpace(data.PersistentCode) != "")
}
func repairLoginIdentityToken(profile *Profile, source *TokenData) error {
corpID := strings.TrimSpace(profile.CorpID)
userID := strings.TrimSpace(profile.UserID)
identityToken := source
if strings.TrimSpace(source.UserID) == "" {
enriched := *source
enriched.UserID = userID
if strings.TrimSpace(enriched.UserName) == "" {
enriched.UserName = strings.TrimSpace(profile.UserName)
}
identityToken = &enriched
}
if err := profilesSaveIdentity(corpID, userID, identityToken); err != nil {
return fmt.Errorf("repair identity token slot %q: %w", TokenAccountForIdentity(corpID, userID), err)
}
return nil
}
// preflightTokenPersistence verifies every persisted token slot, including
// unregistered/orphan ciphertext. Keep this full-inventory validator for
// migration, export and explicit storage diagnostics; login must use the
// schema-only and target-only preflights instead so an unrelated damaged
// account cannot block reauthorization.
func preflightTokenPersistence(configDir string) error {
if h := edition.Get(); h.SaveToken != nil {
return nil
@@ -191,10 +357,11 @@ func preflightTokenPersistence(configDir string) error {
return nil
}
// preflightTokenRefreshPersistence checks only the slots a refresh can write.
// An unrelated broken profile must not prevent the current profile from using
// its still-valid credentials.
func preflightTokenRefreshPersistence(configDir string, data *TokenData) error {
// preflightTokenWritePersistence checks only the slots SaveTokenData can write
// for data under the current runtime selector. It is shared by login and
// refresh so both paths stay aligned with the same identity/org/global mirror
// isolation rules.
func preflightTokenWritePersistence(configDir string, data *TokenData) error {
if h := edition.Get(); h.SaveToken != nil {
return nil
}
@@ -206,33 +373,46 @@ func preflightTokenRefreshPersistence(configDir string, data *TokenData) error {
return err
}
if _, err := LoadTokenDataKeychain(); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
return fmt.Errorf("legacy token slot %q is unreadable: %w", keychain.AccountToken, err)
plan := planTokenPersistenceWrites(cfg, data, RuntimeProfile())
if err := validateTokenPersistenceWritePlan(cfg, data, plan); err != nil {
return err
}
if data == nil || strings.TrimSpace(data.CorpID) == "" {
return nil
}
corpID := strings.TrimSpace(data.CorpID)
userID := strings.TrimSpace(data.UserID)
if userID != "" {
if _, err := LoadTokenDataKeychainForIdentity(corpID, userID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
return fmt.Errorf("identity token slot %q is unreadable: %w", TokenAccountForIdentity(corpID, userID), err)
if plan.WriteGlobal {
if _, err := LoadTokenDataKeychain(); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
return fmt.Errorf("legacy token slot %q is unreadable: %w", keychain.AccountToken, err)
}
}
checkOrganizationMirror := true
if _, _, exact := ParseIdentitySelector(RuntimeProfile()); exact {
checkOrganizationMirror =
exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]) ==
profileSelector(corpID, userID)
if plan.CorpID == "" {
return nil
}
if checkOrganizationMirror {
if _, err := LoadTokenDataKeychainForCorpID(corpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
return fmt.Errorf("profile token slot %q is unreadable: %w", TokenAccountForCorpID(corpID), err)
if plan.WriteIdentity {
if _, err := LoadTokenDataKeychainForIdentity(plan.CorpID, plan.UserID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
return fmt.Errorf(
"identity token slot %q is unreadable: %w",
TokenAccountForIdentity(plan.CorpID, plan.UserID),
err,
)
}
}
if plan.WriteOrganization {
if _, err := LoadTokenDataKeychainForCorpID(plan.CorpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
return fmt.Errorf(
"profile token slot %q is unreadable: %w",
TokenAccountForCorpID(plan.CorpID),
err,
)
}
}
return nil
}
// preflightTokenRefreshPersistence checks only the slots a refresh can write.
// An unrelated broken profile must not prevent the current profile from using
// its still-valid credentials.
func preflightTokenRefreshPersistence(configDir string, data *TokenData) error {
return preflightTokenWritePersistence(configDir, data)
}
// DeleteTokenDataKeychain removes TokenData from the platform keychain.
func DeleteTokenDataKeychain() error {
return authKeychainRemove(keychain.Service, keychain.AccountToken)
@@ -0,0 +1,288 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"errors"
"testing"
)
func TestCrossPlatformCoverageLegacySelectorCompatibilityEdges(t *testing.T) {
upgradeDir := t.TempDir()
upgradeCfg := &ProfilesConfig{
Version: profilesVersion,
Profiles: []Profile{{
Name: "Legacy Organization",
CorpID: "corp_upgrade_fixture",
}},
}
if err := upsertProfileFromToken(upgradeDir, upgradeCfg, &TokenData{
CorpID: "corp_upgrade_fixture",
UserID: "identity_upgrade_fixture",
UserName: "Upgraded Account",
}, false); err != nil {
t.Fatalf("upsertProfileFromToken(upgrade legacy profile) error = %v", err)
}
if len(upgradeCfg.Profiles) != 1 || upgradeCfg.Profiles[0].UserID != "identity_upgrade_fixture" {
t.Fatalf("upgraded profiles = %#v", upgradeCfg.Profiles)
}
renameDir := t.TempDir()
renameCfg := &ProfilesConfig{
Version: profilesVersion,
Profiles: []Profile{
{Name: "duplicate", CorpID: "corp_rename_fixture"},
{Name: "duplicate", CorpID: "corp_rename_fixture", UserID: "identity_exact_fixture"},
},
}
if err := upsertProfileFromToken(renameDir, renameCfg, &TokenData{
CorpID: "corp_rename_fixture",
CorpName: "Renamed Organization",
}, false); err != nil {
t.Fatalf("upsertProfileFromToken(rename blank profile) error = %v", err)
}
if renameCfg.Profiles[0].Name != "Renamed Organization" {
t.Fatalf("blank profile name = %q, want conflict-free organization name", renameCfg.Profiles[0].Name)
}
blank := Profile{CorpID: "corp_selector_fixture"}
if got := storedProfileSelector(nil, nil); got != "" {
t.Fatalf("storedProfileSelector(nil profile) = %q", got)
}
if got := storedProfileSelector(nil, &blank); got != blank.CorpID {
t.Fatalf("storedProfileSelector(nil config) = %q", got)
}
if localProfileSelectorIsSafe(nil, &blank, "local") ||
localProfileSelectorIsSafe(&ProfilesConfig{}, nil, "local") {
t.Fatal("nil selector inputs were treated as safe")
}
if got := unresolvedProfileSelector(" "); got != "" {
t.Fatalf("unresolvedProfileSelector(blank) = %q", got)
}
if _, ok := parseUnresolvedProfileSelector(unresolvedProfileSelectorPrefix + "!"); ok {
t.Fatal("invalid base64 legacy selector parsed successfully")
}
if _, ok := parseUnresolvedProfileSelector(unresolvedProfileSelectorPrefix + "IA"); ok {
t.Fatal("blank decoded legacy selector parsed successfully")
}
previousRuntime := RuntimeProfile()
SetRuntimeProfile("")
t.Cleanup(func() { SetRuntimeProfile(previousRuntime) })
if got := StableTokenProfileSelector(t.TempDir(), nil); got != "" {
t.Fatalf("StableTokenProfileSelector(nil) = %q", got)
}
ambiguousDir := t.TempDir()
ambiguousCfg := &ProfilesConfig{
Version: profilesVersion,
CurrentProfile: "corp_ambiguous_fixture",
Profiles: []Profile{
{Name: "First", CorpID: "corp_ambiguous_fixture", UserID: "identity_first_fixture"},
{Name: "Second", CorpID: "corp_ambiguous_fixture", UserID: "identity_second_fixture"},
},
}
if err := SaveProfiles(ambiguousDir, ambiguousCfg); err != nil {
t.Fatalf("SaveProfiles(ambiguous current) error = %v", err)
}
ambiguousToken := &TokenData{CorpID: "corp_ambiguous_fixture", UserID: "identity_first_fixture"}
if got, want := StableTokenProfileSelector(ambiguousDir, ambiguousToken), "corp_ambiguous_fixture:identity_first_fixture"; got != want {
t.Fatalf("StableTokenProfileSelector(ambiguous organization) = %q, want %q", got, want)
}
reserved := unresolvedProfileSelector("corp_missing_fixture")
emptyCfg := &ProfilesConfig{}
if _, _, err := resolveProfileSelection("", emptyCfg, reserved); err == nil {
t.Fatal("missing reserved profile selection succeeded")
}
if _, _, err := resolveProfileDeletionSelection(emptyCfg, reserved); err == nil {
t.Fatal("missing reserved profile deletion succeeded")
}
if got := canonicalStoredSelector(emptyCfg, reserved); got != "" {
t.Fatalf("canonical missing reserved selector = %q", got)
}
if !selectorTargetsCorp(reserved, "corp_missing_fixture") {
t.Fatal("reserved selector did not target its organization")
}
localCfg := &ProfilesConfig{Profiles: []Profile{{
Name: "local-profile-fixture",
CorpID: "corp_local_fixture",
UserID: "identity_local_fixture",
}}}
if got, want := canonicalStoredSelector(localCfg, "local-profile-fixture"), "corp_local_fixture:identity_local_fixture"; got != want {
t.Fatalf("canonical local selector = %q, want %q", got, want)
}
if unresolvedProfileForCorp(nil, "corp") != nil || unresolvedProfileForLocalName(nil, "local") != nil {
t.Fatal("nil profile registry returned an unresolved profile")
}
if unresolvedProfileForLocalName(localCfg, " ") != nil {
t.Fatal("blank local name returned an unresolved profile")
}
duplicateCfg := &ProfilesConfig{Profiles: []Profile{
{Name: "duplicate-blank", CorpID: "corp_duplicate_one"},
{Name: "Exact One", CorpID: "corp_duplicate_one", UserID: "identity_one"},
{Name: "duplicate-blank", CorpID: "corp_duplicate_two"},
{Name: "Exact Two", CorpID: "corp_duplicate_two", UserID: "identity_two"},
}}
if unresolvedProfileForLocalName(duplicateCfg, "duplicate-blank") != nil {
t.Fatal("duplicate unresolved local name selected an arbitrary profile")
}
oldLoadCorp := tokenLoadKeychainForCorpID
t.Cleanup(func() { tokenLoadKeychainForCorpID = oldLoadCorp })
tokenLoadKeychainForCorpID = func(string) (*TokenData, error) { return nil, nil }
if _, err := tokenLoadProfileIdentity(Profile{CorpID: "corp_nil_token_fixture"}); !errors.Is(err, ErrTokenDataNotFound) {
t.Fatalf("nil organization token error = %v", err)
}
}
func TestCrossPlatformCoverageLegacyProfileLifecycleErrorEdges(t *testing.T) {
oldEnsure := profilesEnsureMigration
oldLoad := profilesLoad
oldSave := profilesSave
oldLoadCorp := profilesLoadCorp
oldLoadLegacy := profilesLoadLegacy
oldLoadIdentity := profilesLoadIdentity
oldSaveCorp := profilesSaveCorp
oldDeleteCorp := profilesDeleteCorp
oldDeleteLegacy := profilesDeleteLegacy
oldDeleteMarker := profilesDeleteMarker
t.Cleanup(func() {
profilesEnsureMigration = oldEnsure
profilesLoad = oldLoad
profilesSave = oldSave
profilesLoadCorp = oldLoadCorp
profilesLoadLegacy = oldLoadLegacy
profilesLoadIdentity = oldLoadIdentity
profilesSaveCorp = oldSaveCorp
profilesDeleteCorp = oldDeleteCorp
profilesDeleteLegacy = oldDeleteLegacy
profilesDeleteMarker = oldDeleteMarker
})
identityFailure := errors.New("identity load failure")
profilesEnsureMigration = func(string) error { return nil }
profilesSave = func(string, *ProfilesConfig) error { return nil }
profilesLoadCorp = func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
profilesLoadLegacy = func() (*TokenData, error) { return nil, ErrTokenDataNotFound }
profilesLoadIdentity = func(string, string) (*TokenData, error) { return nil, identityFailure }
profilesSaveCorp = func(string, *TokenData) error { return nil }
profilesDeleteCorp = func(string) error { return nil }
profilesDeleteLegacy = func() error { return nil }
profilesDeleteMarker = func(string) error { return nil }
setCurrentCfg := &ProfilesConfig{
Version: profilesVersion,
CurrentProfile: "corp_set_fixture:identity_set_fixture",
OrgCurrentProfiles: map[string]string{
"corp_set_fixture": "corp_set_fixture:identity_set_fixture",
},
Profiles: []Profile{{
Name: "Set Account",
CorpID: "corp_set_fixture",
UserID: "identity_set_fixture",
}},
}
profilesLoad = func(string) (*ProfilesConfig, error) { return setCurrentCfg, nil }
if _, err := setCurrentProfileLocked(t.TempDir(), "corp_set_fixture:identity_set_fixture"); !errors.Is(err, identityFailure) {
t.Fatalf("setCurrentProfileLocked sync error = %v", err)
}
usePreviousCfg := &ProfilesConfig{
Version: profilesVersion,
CurrentProfile: "corp_previous_fixture:identity_current_fixture",
PreviousProfile: "corp_previous_fixture:identity_previous_fixture",
OrgCurrentProfiles: map[string]string{
"corp_previous_fixture": "corp_previous_fixture:identity_current_fixture",
},
Profiles: []Profile{
{Name: "Current", CorpID: "corp_previous_fixture", UserID: "identity_current_fixture"},
{Name: "Previous", CorpID: "corp_previous_fixture", UserID: "identity_previous_fixture"},
},
}
profilesLoad = func(string) (*ProfilesConfig, error) { return usePreviousCfg, nil }
if _, err := usePreviousProfileLocked(t.TempDir()); !errors.Is(err, identityFailure) {
t.Fatalf("usePreviousProfileLocked sync error = %v", err)
}
snapshotFailure := errors.New("organization snapshot failure")
profilesLoadCorp = func(string) (*TokenData, error) { return nil, snapshotFailure }
if _, err := snapshotProfileSelectionMirrors(t.TempDir(), "corp_snapshot_fixture", true); !errors.Is(err, snapshotFailure) {
t.Fatalf("snapshot organization error = %v", err)
}
profilesLoadCorp = func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
operationFailure := errors.New("selection operation failure")
organizationRestoreFailure := errors.New("organization restore failure")
profilesSaveCorp = func(string, *TokenData) error { return organizationRestoreFailure }
withOrganization := profileSelectionMirrorSnapshot{
organization: tokenSlotSnapshot{known: true, exists: true, token: &TokenData{CorpID: "corp_rollback_fixture"}},
marker: tokenMarkerSnapshot{known: true},
}
if err := rollbackProfileSelection(t.TempDir(), &ProfilesConfig{}, "corp_rollback_fixture", withOrganization, operationFailure); !errors.Is(err, operationFailure) || !errors.Is(err, organizationRestoreFailure) {
t.Fatalf("rollback organization save error = %v", err)
}
organizationDeleteFailure := errors.New("organization delete failure")
profilesSaveCorp = func(string, *TokenData) error { return nil }
profilesDeleteCorp = func(string) error { return organizationDeleteFailure }
withoutOrganization := profileSelectionMirrorSnapshot{
organization: tokenSlotSnapshot{known: true},
marker: tokenMarkerSnapshot{known: true},
}
if err := rollbackProfileSelection(t.TempDir(), &ProfilesConfig{}, "corp_rollback_fixture", withoutOrganization, operationFailure); !errors.Is(err, operationFailure) || !errors.Is(err, organizationDeleteFailure) {
t.Fatalf("rollback organization delete error = %v", err)
}
profilesDeleteCorp = func(string) error { return nil }
remainingCfg := &ProfilesConfig{
Version: profilesVersion,
CurrentProfile: "corp_removed_fixture:identity_removed_fixture",
OrgCurrentProfiles: map[string]string{
"corp_removed_fixture": "corp_removed_fixture:identity_removed_fixture",
},
Profiles: []Profile{
{Name: "Removed", CorpID: "corp_removed_fixture", UserID: "identity_removed_fixture"},
{Name: "Remaining", CorpID: "corp_remaining_fixture", UserID: "identity_remaining_fixture"},
},
}
profilesLoad = func(string) (*ProfilesConfig, error) { return remainingCfg, nil }
if _, err := removeProfileLocked(t.TempDir(), "corp_removed_fixture:identity_removed_fixture"); err != nil {
t.Fatalf("removeProfileLocked(single fallback) error = %v", err)
}
if remainingCfg.CurrentProfile != "corp_remaining_fixture:identity_remaining_fixture" {
t.Fatalf("fallback current profile = %q", remainingCfg.CurrentProfile)
}
blankCfg := &ProfilesConfig{
Version: profilesVersion,
CurrentProfile: "corp_blank_fixture:identity_exact_fixture",
PreviousProfile: "legacy-blank-fixture",
OrgCurrentProfiles: map[string]string{
"corp_blank_fixture": "corp_blank_fixture:identity_exact_fixture",
},
Profiles: []Profile{
{Name: "legacy-blank-fixture", CorpID: "corp_blank_fixture"},
{Name: "Exact", CorpID: "corp_blank_fixture", UserID: "identity_exact_fixture"},
},
}
profilesLoad = func(string) (*ProfilesConfig, error) { return blankCfg, nil }
if _, err := removeProfileLocked(t.TempDir(), "corp_blank_fixture:identity_exact_fixture"); err != nil {
t.Fatalf("removeProfileLocked(blank fallback) error = %v", err)
}
if blankCfg.CurrentProfile != "corp_blank_fixture" || blankCfg.OrgCurrentProfiles["corp_blank_fixture"] != "" {
t.Fatalf("blank fallback selection = current %q org %q", blankCfg.CurrentProfile, blankCfg.OrgCurrentProfiles["corp_blank_fixture"])
}
}
@@ -0,0 +1,879 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"encoding/json"
"errors"
"os"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
// The fixture builders spell out the exact v1 profiles and token JSON keys.
// They deliberately avoid the current TokenData and ProfilesConfig serializers
// so that historical field omission and account names stay part of the upgrade
// contract exercised by these tests.
func TestCrossPlatformCoverageV1044GlobalSlotWithoutProfilesMigrates(t *testing.T) {
for _, tc := range []struct {
name string
userID string
}{
{name: "known user", userID: "legacy-user-v1044"},
{name: "unresolved external worker"},
} {
t.Run(tc.name, func(t *testing.T) {
cleanupHistoricalKeychain(t)
configDir := t.TempDir()
corpID := "ding_v1044_" + strings.ReplaceAll(tc.name, " ", "_")
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
t, "global-v1044-"+tc.name, corpID, "V1044 Org", tc.userID, "",
))
loaded, err := LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if loaded.CorpID != corpID || loaded.UserID != tc.userID {
t.Fatalf("migrated v1.0.44 token = %#v", loaded)
}
if !TokenDataExistsKeychainForCorpID(corpID) {
t.Fatal("v1.0.44 global token was not copied to its organization slot")
}
if tc.userID != "" && !TokenDataExistsKeychainForIdentity(corpID, tc.userID) {
t.Fatal("v1.0.44 known identity slot was not created")
}
})
}
}
func TestCrossPlatformCoverageV1050AndV1051GlobalSlotWithV1ProfilesMigratesIdentity(t *testing.T) {
tests := []struct {
name string
corpID string
corpName string
userID string
userName string
tokenHasUID bool
}{
{
name: "v1.0.50 token and profile both carry userId",
corpID: "ding_v1050",
corpName: "V1050 Org",
userID: "legacy-user-v1050",
userName: "V1050 User",
tokenHasUID: true,
},
{
name: "v1.0.51 profile supplies omitted token userId",
corpID: "ding_v1051",
corpName: "V1051 Org",
userID: "legacy-user-v1051",
userName: "V1051 User",
tokenHasUID: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cleanupHistoricalKeychain(t)
configDir := t.TempDir()
writeHistoricalV1Profiles(t, configDir, []historicalV1Profile{{
name: tc.corpName,
corpID: tc.corpID,
corpName: tc.corpName,
userID: tc.userID,
userName: tc.userName,
clientID: "ding-client-" + tc.corpID,
}}, tc.corpID, "", tc.corpID)
tokenUserID, tokenUserName := "", ""
if tc.tokenHasUID {
tokenUserID, tokenUserName = tc.userID, tc.userName
}
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
t,
"global-"+tc.corpID,
tc.corpID,
tc.corpName,
tokenUserID,
tokenUserName,
))
// An ordinary first read is the upgrade trigger. A recoverable global
// token must populate both the organization and exact-identity slots
// even when a version-1 profiles registry already exists.
if _, err := LoadTokenData(configDir); err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
migrated, err := LoadTokenDataKeychainForIdentity(tc.corpID, tc.userID)
if err != nil {
t.Fatalf("LoadTokenDataKeychainForIdentity(%q, %q) error = %v", tc.corpID, tc.userID, err)
}
if migrated.CorpID != tc.corpID || migrated.UserID != tc.userID {
t.Fatalf("migrated identity token = %#v", migrated)
}
if migrated.AccessToken != "global-"+tc.corpID ||
migrated.RefreshToken != "refresh-global-"+tc.corpID ||
migrated.PersistentCode != "persistent-global-"+tc.corpID ||
migrated.ClientID != "ding-client-historical" ||
migrated.Source != "mcp" {
t.Fatalf("migrated token fields were not preserved: %#v", migrated)
}
orgMirror, err := LoadTokenDataKeychainForCorpID(tc.corpID)
if err != nil {
t.Fatalf("LoadTokenDataKeychainForCorpID(%q) error = %v", tc.corpID, err)
}
if orgMirror.AccessToken != "global-"+tc.corpID {
t.Fatalf("organization token slot %q = %#v", TokenAccountForCorpID(tc.corpID), orgMirror)
}
if !tc.tokenHasUID && orgMirror.UserID != "" {
t.Fatalf("organization mirror inferred userId %q; want untouched historical blob", orgMirror.UserID)
}
})
}
}
func TestCrossPlatformCoverageV1052RawMultiOrganizationSlotsMigrateEveryIdentity(t *testing.T) {
cleanupHistoricalKeychain(t)
configDir := t.TempDir()
organizations := seedHistoricalV1052MultiOrganizationState(t, configDir)
// Reading only the current organization must upgrade the complete registry,
// including inactive organizations that are not selected.
loaded, err := LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if loaded.CorpID != organizations[1].corpID || loaded.UserID != organizations[1].userID {
t.Fatalf("current token after migration = %#v", loaded)
}
assertHistoricalV1052IdentitySlots(t, organizations, nil)
}
func TestCrossPlatformCoverageV1052UnresolvedMultiOrganizationProfilesRemainUsable(t *testing.T) {
cleanupHistoricalKeychain(t)
configDir := t.TempDir()
organizations := []historicalV1052Organization{
{corpID: "ding_v1052_external_a", corpName: "External Org A", accessToken: "external-access-a"},
{corpID: "ding_v1052_external_b", corpName: "External Org B", accessToken: "external-access-b"},
{corpID: "ding_v1052_external_c", corpName: "External Org C", accessToken: "external-access-c"},
}
profiles := make([]historicalV1Profile, 0, len(organizations))
for _, organization := range organizations {
profiles = append(profiles, historicalV1Profile{
name: organization.corpName, corpID: organization.corpID, corpName: organization.corpName,
})
seedHistoricalTokenSlot(t, TokenAccountForCorpID(organization.corpID), historicalTokenJSON(
t, organization.accessToken, organization.corpID, organization.corpName, "", "",
))
}
writeHistoricalV1Profiles(
t,
configDir,
profiles,
organizations[0].corpID,
organizations[0].corpID,
organizations[1].corpID,
)
if err := EnsureProfilesMigration(configDir); err != nil {
t.Fatalf("EnsureProfilesMigration() error = %v", err)
}
for _, organization := range organizations {
loaded, err := LoadTokenDataForProfile(configDir, organization.corpID)
if err != nil {
t.Fatalf("LoadTokenDataForProfile(%q) error = %v", organization.corpID, err)
}
if loaded.AccessToken != organization.accessToken || loaded.CorpID != organization.corpID || loaded.UserID != "" {
t.Fatalf("unresolved organization token for %q = %#v", organization.corpID, loaded)
}
}
}
func TestCrossPlatformCoverageV1052FirstSaveMigratesAllOrganizationsBeforeV2Commit(t *testing.T) {
cleanupHistoricalKeychain(t)
configDir := t.TempDir()
organizations := seedHistoricalV1052MultiOrganizationState(t, configDir)
// A login or refresh can make SaveTokenData the first new-version action.
// It must migrate every old organization before profiles.json becomes v2,
// otherwise the remaining organization mirrors are stranded permanently.
firstWrite := &TokenData{
AccessToken: "new-first-action-access",
RefreshToken: "new-first-action-refresh",
PersistentCode: "new-first-action-persistent",
CorpID: organizations[1].corpID,
CorpName: organizations[1].corpName,
UserID: organizations[1].userID,
UserName: organizations[1].userName,
ClientID: "ding-client-new-first-action",
Source: "mcp",
}
if err := SaveTokenData(configDir, firstWrite); err != nil {
t.Fatalf("SaveTokenData(first new-version action) error = %v", err)
}
assertHistoricalV1052IdentitySlots(t, organizations, map[string]string{
organizations[1].corpID: firstWrite.AccessToken,
})
migratedCurrent, err := LoadTokenDataKeychainForIdentity(firstWrite.CorpID, firstWrite.UserID)
if err != nil {
t.Fatalf("LoadTokenDataKeychainForIdentity(first write) error = %v", err)
}
if migratedCurrent.RefreshToken != firstWrite.RefreshToken ||
migratedCurrent.PersistentCode != firstWrite.PersistentCode ||
migratedCurrent.ClientID != firstWrite.ClientID ||
migratedCurrent.Source != firstWrite.Source {
t.Fatalf("first-write identity token fields were not preserved: %#v", migratedCurrent)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.Version != profilesVersion || len(cfg.Profiles) != len(organizations) {
t.Fatalf("profiles after first save = %#v", cfg)
}
}
func TestCrossPlatformCoverageLegacyGlobalFallbackIsStrictlyScoped(t *testing.T) {
t.Run("different organization is never reused", func(t *testing.T) {
cleanupHistoricalKeychain(t)
configDir := t.TempDir()
writeHistoricalV1Profiles(t, configDir, []historicalV1Profile{{
name: "Expected Org",
corpID: "ding_expected",
corpName: "Expected Org",
userID: "expected-user",
userName: "Expected User",
clientID: "ding-client-expected",
}}, "ding_expected", "", "ding_expected")
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
t,
"wrong-org-access",
"ding_other",
"Other Org",
"other-user",
"Other User",
))
if err := EnsureProfilesMigration(configDir); err != nil {
t.Fatalf("EnsureProfilesMigration() error = %v", err)
}
if TokenDataExistsKeychainForCorpID("ding_expected") ||
TokenDataExistsKeychainForIdentity("ding_expected", "expected-user") {
t.Fatal("global token from another organization was reused")
}
})
t.Run("version 2 empty tombstone never imports global slot", func(t *testing.T) {
cleanupHistoricalKeychain(t)
configDir := t.TempDir()
if err := SaveProfiles(configDir, &ProfilesConfig{Version: profilesVersion}); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
t,
"stale-v2-global",
"ding_v2",
"V2 Org",
"v2-user",
"V2 User",
))
if err := EnsureProfilesMigration(configDir); err != nil {
t.Fatalf("EnsureProfilesMigration() error = %v", err)
}
if TokenDataExistsKeychainForCorpID("ding_v2") ||
TokenDataExistsKeychainForIdentity("ding_v2", "v2-user") {
t.Fatal("version 2 logout tombstone imported the stale global slot")
}
})
t.Run("multiple same organization accounts never receive guessed identity", func(t *testing.T) {
cleanupHistoricalKeychain(t)
configDir := t.TempDir()
writeHistoricalV1Profiles(t, configDir, []historicalV1Profile{
{
name: "Shared Org One",
corpID: "ding_shared",
corpName: "Shared Org",
userID: "shared-user-one",
userName: "Shared User One",
clientID: "ding-client-shared",
},
{
name: "Shared Org Two",
corpID: "ding_shared",
corpName: "Shared Org",
userID: "shared-user-two",
userName: "Shared User Two",
clientID: "ding-client-shared",
},
}, "ding_shared", "", "ding_shared")
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
t,
"shared-without-user",
"ding_shared",
"Shared Org",
"",
"",
))
if err := EnsureProfilesMigration(configDir); err != nil {
t.Fatalf("EnsureProfilesMigration() error = %v", err)
}
if !TokenDataExistsKeychainForCorpID("ding_shared") {
t.Fatal("matching organization mirror was not restored")
}
for _, userID := range []string{"shared-user-one", "shared-user-two"} {
if TokenDataExistsKeychainForIdentity("ding_shared", userID) {
t.Fatalf("ambiguous global token was copied to identity %q", userID)
}
}
})
}
func TestCrossPlatformCoverageV1053PartialV2RegistryRepairsFromMatchingGlobalSlot(t *testing.T) {
tests := []struct {
name string
profileUserID string
tokenUserID string
}{
{
name: "matching token identity",
profileUserID: "user_v1053_matching",
tokenUserID: "user_v1053_matching",
},
{name: "token omitted identity", profileUserID: "user_v1053_token_omitted"},
{name: "sole unresolved profile"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cleanupHistoricalKeychain(t)
configDir := t.TempDir()
suffix := strings.ReplaceAll(tc.name, " ", "_")
corpID := "ding_v1053_partial_" + suffix
profile := Profile{
Name: "V1053 Partial Org",
CorpID: corpID,
CorpName: "V1053 Partial Org",
UserID: tc.profileUserID,
UserName: "V1053 Partial User",
}
identityLoads := 0
if profile.UserID == "" {
originalLoadIdentity := profilesLoadIdentity
profilesLoadIdentity = func(corpID, userID string) (*TokenData, error) {
identityLoads++
return originalLoadIdentity(corpID, userID)
}
t.Cleanup(func() { profilesLoadIdentity = originalLoadIdentity })
}
selector := ProfileSelector(profile)
if err := SaveProfiles(configDir, &ProfilesConfig{
Version: profilesVersion,
CurrentProfile: selector,
OrgCurrentProfiles: map[string]string{corpID: selector},
Profiles: []Profile{profile},
}); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
t,
"v1053-global-"+suffix,
corpID,
profile.CorpName,
tc.tokenUserID,
"",
))
if TokenDataExistsKeychainForCorpID(corpID) {
t.Fatal("partial v2 fixture unexpectedly contained an organization or identity slot")
}
if profile.UserID != "" && TokenDataExistsKeychainForIdentity(corpID, profile.UserID) {
t.Fatal("partial v2 fixture unexpectedly contained an identity slot")
}
loaded, err := LoadTokenDataForProfile(configDir, selector)
if err != nil {
t.Fatalf("LoadTokenDataForProfile() error = %v", err)
}
if loaded.AccessToken != "v1053-global-"+suffix ||
loaded.CorpID != corpID || loaded.UserID != profile.UserID {
t.Fatalf("repaired v2 token = %#v", loaded)
}
if profile.UserID != "" {
identityToken, identityErr := LoadTokenDataKeychainForIdentity(corpID, profile.UserID)
if identityErr != nil {
t.Fatalf("LoadTokenDataKeychainForIdentity() error = %v", identityErr)
}
if identityToken.AccessToken != loaded.AccessToken || identityToken.UserID != profile.UserID {
t.Fatalf("repaired identity token = %#v", identityToken)
}
}
orgMirror, err := LoadTokenDataKeychainForCorpID(corpID)
if err != nil {
t.Fatalf("LoadTokenDataKeychainForCorpID() error = %v", err)
}
if orgMirror.AccessToken != loaded.AccessToken || orgMirror.UserID != tc.tokenUserID {
t.Fatalf("repaired organization mirror = %#v", orgMirror)
}
if identityLoads != 0 {
t.Fatalf("unresolved profile caused %d identity-slot reads; want 0", identityLoads)
}
})
}
t.Run("matching organization among multiple organizations", func(t *testing.T) {
cleanupHistoricalKeychain(t)
configDir := t.TempDir()
profiles := []Profile{
{Name: "Partial Org A", CorpID: "ding_v1053_partial_a", UserID: "user_v1053_partial_a"},
{Name: "Partial Org B", CorpID: "ding_v1053_partial_b", UserID: "user_v1053_partial_b"},
}
if err := SaveProfiles(configDir, &ProfilesConfig{
Version: profilesVersion,
CurrentProfile: ProfileSelector(profiles[1]),
Profiles: profiles,
}); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
t,
"v1053-global-multi-org",
profiles[1].CorpID,
profiles[1].Name,
profiles[1].UserID,
"",
))
if err := EnsureProfilesMigration(configDir); err != nil {
t.Fatalf("EnsureProfilesMigration() error = %v", err)
}
if TokenDataExistsKeychainForCorpID(profiles[0].CorpID) ||
TokenDataExistsKeychainForIdentity(profiles[0].CorpID, profiles[0].UserID) {
t.Fatal("global token was copied into the non-matching organization")
}
repaired, err := LoadTokenDataKeychainForIdentity(profiles[1].CorpID, profiles[1].UserID)
if err != nil {
t.Fatalf("LoadTokenDataKeychainForIdentity(matching organization) error = %v", err)
}
if repaired.AccessToken != "v1053-global-multi-org" || repaired.UserID != profiles[1].UserID {
t.Fatalf("multi-organization v2 repair token = %#v", repaired)
}
})
}
func TestCrossPlatformCoverageV1053PartialV2RegistryRejectsUnsafeGlobalSlot(t *testing.T) {
t.Run("global token organization differs", func(t *testing.T) {
cleanupHistoricalKeychain(t)
configDir := t.TempDir()
profile := Profile{Name: "Expected Org", CorpID: "ding_v2_expected", UserID: "user_v2_expected"}
if err := SaveProfiles(configDir, &ProfilesConfig{
Version: profilesVersion,
CurrentProfile: ProfileSelector(profile),
Profiles: []Profile{profile},
}); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
t, "cross-corp-global", "ding_v2_other", "Other Org", profile.UserID, "",
))
if err := EnsureProfilesMigration(configDir); err != nil {
t.Fatalf("EnsureProfilesMigration() error = %v", err)
}
if TokenDataExistsKeychainForCorpID(profile.CorpID) ||
TokenDataExistsKeychainForIdentity(profile.CorpID, profile.UserID) {
t.Fatal("cross-organization global token was imported")
}
})
t.Run("unresolved profile rejects global token identity", func(t *testing.T) {
cleanupHistoricalKeychain(t)
configDir := t.TempDir()
profile := Profile{Name: "Unresolved External", CorpID: "ding_v2_unresolved"}
if err := SaveProfiles(configDir, &ProfilesConfig{
Version: profilesVersion,
CurrentProfile: ProfileSelector(profile),
Profiles: []Profile{profile},
}); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
t, "unexpected-identity-global", profile.CorpID, profile.Name, "user_v2_unexpected", "",
))
if err := EnsureProfilesMigration(configDir); err != nil {
t.Fatalf("EnsureProfilesMigration() error = %v", err)
}
if TokenDataExistsKeychainForCorpID(profile.CorpID) {
t.Fatal("global token userId was attached to an unresolved profile")
}
})
t.Run("global token identity differs", func(t *testing.T) {
cleanupHistoricalKeychain(t)
configDir := t.TempDir()
profile := Profile{Name: "Expected User", CorpID: "ding_v2_uid", UserID: "user_v2_expected"}
if err := SaveProfiles(configDir, &ProfilesConfig{
Version: profilesVersion,
CurrentProfile: ProfileSelector(profile),
Profiles: []Profile{profile},
}); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
t, "wrong-user-global", profile.CorpID, profile.Name, "user_v2_other", "",
))
if err := EnsureProfilesMigration(configDir); err != nil {
t.Fatalf("EnsureProfilesMigration() error = %v", err)
}
if TokenDataExistsKeychainForCorpID(profile.CorpID) ||
TokenDataExistsKeychainForIdentity(profile.CorpID, profile.UserID) {
t.Fatal("global token with a different userId was imported")
}
})
t.Run("multiple accounts in one organization stay unresolved", func(t *testing.T) {
cleanupHistoricalKeychain(t)
configDir := t.TempDir()
corpID := "ding_v2_shared"
profiles := []Profile{
{Name: "Shared User One", CorpID: corpID, UserID: "user_v2_shared_one"},
{Name: "Shared User Two", CorpID: corpID, UserID: "user_v2_shared_two"},
}
if err := SaveProfiles(configDir, &ProfilesConfig{
Version: profilesVersion,
CurrentProfile: ProfileSelector(profiles[0]),
Profiles: profiles,
}); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
t, "ambiguous-global", corpID, "Shared Org", profiles[0].UserID, "",
))
if err := EnsureProfilesMigration(configDir); err != nil {
t.Fatalf("EnsureProfilesMigration() error = %v", err)
}
if TokenDataExistsKeychainForCorpID(corpID) {
t.Fatal("multi-account organization imported the mutable global mirror")
}
for _, profile := range profiles {
if TokenDataExistsKeychainForIdentity(corpID, profile.UserID) {
t.Fatalf("multi-account global token was copied to identity %q", profile.UserID)
}
}
})
t.Run("existing exact identity is not overwritten", func(t *testing.T) {
cleanupHistoricalKeychain(t)
configDir := t.TempDir()
profile := Profile{Name: "Existing User", CorpID: "ding_v2_existing", UserID: "user_v2_existing"}
if err := SaveProfiles(configDir, &ProfilesConfig{
Version: profilesVersion,
CurrentProfile: ProfileSelector(profile),
Profiles: []Profile{profile},
}); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
seedHistoricalTokenSlot(t, TokenAccountForIdentity(profile.CorpID, profile.UserID), historicalTokenJSON(
t, "existing-exact", profile.CorpID, profile.Name, profile.UserID, "",
))
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
t, "stale-global", profile.CorpID, profile.Name, profile.UserID, "",
))
if err := EnsureProfilesMigration(configDir); err != nil {
t.Fatalf("EnsureProfilesMigration() error = %v", err)
}
if TokenDataExistsKeychainForCorpID(profile.CorpID) {
t.Fatal("global mirror recreated an organization slot beside an existing exact identity")
}
exact, err := LoadTokenDataKeychainForIdentity(profile.CorpID, profile.UserID)
if err != nil {
t.Fatalf("LoadTokenDataKeychainForIdentity() error = %v", err)
}
if exact.AccessToken != "existing-exact" {
t.Fatalf("existing exact identity was overwritten: %#v", exact)
}
})
}
func TestCrossPlatformCoverageLegacyMigrationPersistenceErrors(t *testing.T) {
oldLoad := profilesLoad
oldSave := profilesSave
oldLoadLegacy := profilesLoadLegacy
oldSaveCorp := profilesSaveCorp
oldLoadCorp := profilesLoadCorp
oldLoadIdentity := profilesLoadIdentity
oldSaveIdentity := profilesSaveIdentity
t.Cleanup(func() {
profilesLoad = oldLoad
profilesSave = oldSave
profilesLoadLegacy = oldLoadLegacy
profilesSaveCorp = oldSaveCorp
profilesLoadCorp = oldLoadCorp
profilesLoadIdentity = oldLoadIdentity
profilesSaveIdentity = oldSaveIdentity
})
fail := errors.New("legacy migration persistence failed")
baseConfig := func(version int) *ProfilesConfig {
return &ProfilesConfig{
Version: version,
Profiles: []Profile{{
Name: "Legacy User", CorpID: "ding_legacy_error", UserID: "legacy-user",
}},
}
}
profilesSave = func(string, *ProfilesConfig) error { return nil }
profilesLoadCorp = func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
profilesLoadIdentity = func(string, string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
profilesSaveIdentity = func(string, string, *TokenData) error { return nil }
t.Run("global compatibility slot read", func(t *testing.T) {
profilesLoad = func(string) (*ProfilesConfig, error) { return baseConfig(1), nil }
profilesLoadLegacy = func() (*TokenData, error) { return nil, fail }
profilesSaveCorp = func(string, *TokenData) error { return nil }
if err := ensureProfilesMigrationLocked("cfg"); !errors.Is(err, fail) {
t.Fatalf("ensureProfilesMigrationLocked() error = %v, want %v", err, fail)
}
})
t.Run("restored organization slot write", func(t *testing.T) {
profilesLoad = func(string) (*ProfilesConfig, error) { return baseConfig(1), nil }
profilesLoadLegacy = func() (*TokenData, error) {
return &TokenData{CorpID: "ding_legacy_error", AccessToken: "legacy-access"}, nil
}
profilesSaveCorp = func(string, *TokenData) error { return fail }
if err := ensureProfilesMigrationLocked("cfg"); !errors.Is(err, fail) {
t.Fatalf("ensureProfilesMigrationLocked() error = %v, want %v", err, fail)
}
})
t.Run("repaired identity slot write", func(t *testing.T) {
profilesLoad = func(string) (*ProfilesConfig, error) { return baseConfig(profilesVersion), nil }
profilesLoadCorp = func(string) (*TokenData, error) {
return &TokenData{CorpID: "ding_legacy_error", AccessToken: "legacy-access"}, nil
}
profilesSaveIdentity = func(string, string, *TokenData) error { return fail }
if err := ensureProfilesMigrationLocked("cfg"); !errors.Is(err, fail) {
t.Fatalf("ensureProfilesMigrationLocked() error = %v, want %v", err, fail)
}
})
t.Run("partial v2 identity slot read", func(t *testing.T) {
profilesLoad = func(string) (*ProfilesConfig, error) { return baseConfig(profilesVersion), nil }
profilesLoadCorp = func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
profilesLoadIdentity = func(string, string) (*TokenData, error) { return nil, fail }
if err := ensureProfilesMigrationLocked("cfg"); !errors.Is(err, fail) {
t.Fatalf("ensureProfilesMigrationLocked() error = %v, want %v", err, fail)
}
})
}
type historicalV1Profile struct {
name string
corpID string
corpName string
userID string
userName string
clientID string
}
type historicalV1052Organization struct {
corpID string
corpName string
userID string
userName string
accessToken string
}
func seedHistoricalV1052MultiOrganizationState(t *testing.T, configDir string) []historicalV1052Organization {
t.Helper()
organizations := []historicalV1052Organization{
{corpID: "ding_v1052_a", corpName: "V1052 Org A", userID: "legacy-user-v1052-a", userName: "V1052 User A", accessToken: "v1052-access-a"},
{corpID: "ding_v1052_b", corpName: "V1052 Org B", userID: "legacy-user-v1052-b", userName: "V1052 User B", accessToken: "v1052-access-b"},
{corpID: "ding_v1052_c", corpName: "V1052 Org C", userID: "legacy-user-v1052-c", userName: "V1052 User C", accessToken: "v1052-access-c"},
}
profiles := make([]historicalV1Profile, 0, len(organizations))
for _, organization := range organizations {
profiles = append(profiles, historicalV1Profile{
name: organization.corpName,
corpID: organization.corpID,
corpName: organization.corpName,
userID: organization.userID,
userName: organization.userName,
clientID: "ding-client-v1052",
})
}
writeHistoricalV1Profiles(
t,
configDir,
profiles,
organizations[0].corpID,
organizations[0].corpID,
organizations[1].corpID,
)
for _, organization := range organizations {
// v1.0.52 MCP responses commonly omitted userId while profiles.json
// retained a uniquely known identity.
seedHistoricalTokenSlot(t, TokenAccountForCorpID(organization.corpID), historicalTokenJSON(
t,
organization.accessToken,
organization.corpID,
organization.corpName,
"",
"",
))
}
// v1.0.52 also mirrored the selected organization into the global account.
current := organizations[1]
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
t,
current.accessToken,
current.corpID,
current.corpName,
"",
"",
))
return organizations
}
func assertHistoricalV1052IdentitySlots(
t *testing.T,
organizations []historicalV1052Organization,
accessOverrides map[string]string,
) {
t.Helper()
for _, organization := range organizations {
migrated, err := LoadTokenDataKeychainForIdentity(organization.corpID, organization.userID)
if err != nil {
t.Errorf("LoadTokenDataKeychainForIdentity(%q, %q) error = %v", organization.corpID, organization.userID, err)
continue
}
wantAccess := organization.accessToken
if override := accessOverrides[organization.corpID]; override != "" {
wantAccess = override
}
if migrated.AccessToken != wantAccess ||
migrated.CorpID != organization.corpID ||
migrated.UserID != organization.userID {
t.Errorf(
"migrated token for %q = %#v, want access=%q userId=%q",
organization.corpID,
migrated,
wantAccess,
organization.userID,
)
}
if accessOverrides[organization.corpID] == "" &&
(migrated.RefreshToken != "refresh-"+organization.accessToken ||
migrated.PersistentCode != "persistent-"+organization.accessToken ||
migrated.ClientID != "ding-client-historical" ||
migrated.Source != "mcp") {
t.Errorf("historical token fields for %q were not preserved: %#v", organization.corpID, migrated)
}
}
}
func historicalTokenJSON(t *testing.T, accessToken, corpID, corpName, userID, userName string) string {
t.Helper()
fixture := map[string]any{
"access_token": accessToken,
"refresh_token": "refresh-" + accessToken,
"persistent_code": "persistent-" + accessToken,
"expires_at": "2030-01-02T03:04:05Z",
"refresh_expires_at": "2030-02-02T03:04:05Z",
"corp_id": corpID,
"corp_name": corpName,
"client_id": "ding-client-historical",
"source": "mcp",
}
if userID != "" {
fixture["user_id"] = userID
}
if userName != "" {
fixture["user_name"] = userName
}
data, err := json.MarshalIndent(fixture, "", " ")
if err != nil {
t.Fatalf("marshal historical token fixture: %v", err)
}
return string(data)
}
func writeHistoricalV1Profiles(
t *testing.T,
configDir string,
profiles []historicalV1Profile,
primaryProfile string,
previousProfile string,
currentProfile string,
) {
t.Helper()
rawProfiles := make([]map[string]any, 0, len(profiles))
for _, profile := range profiles {
rawProfiles = append(rawProfiles, map[string]any{
"name": profile.name,
"corpId": profile.corpID,
"corpName": profile.corpName,
"userId": profile.userID,
"userName": profile.userName,
"clientId": profile.clientID,
"status": "active",
"expiresAt": "2030-01-02T03:04:05Z",
"refreshExpAt": "2030-02-02T03:04:05Z",
})
}
fixture := map[string]any{
"version": 1,
"primaryProfile": primaryProfile,
"currentProfile": currentProfile,
"previousProfile": previousProfile,
"profiles": rawProfiles,
}
data, err := json.MarshalIndent(fixture, "", " ")
if err != nil {
t.Fatalf("marshal historical profiles fixture: %v", err)
}
if err := os.MkdirAll(configDir, 0o700); err != nil {
t.Fatalf("create historical config directory: %v", err)
}
if err := os.WriteFile(ProfilesPath(configDir), append(data, '\n'), 0o600); err != nil {
t.Fatalf("write historical profiles.json: %v", err)
}
}
func seedHistoricalTokenSlot(t *testing.T, account, raw string) {
t.Helper()
if err := keychain.Set(keychain.Service, account, raw); err != nil {
t.Fatalf("seed historical keychain account %q: %v", account, err)
}
}
func cleanupHistoricalKeychain(t *testing.T) {
t.Helper()
t.Setenv(keychain.DisableKeychainEnv, "1")
cleanupKeychain(t)
}
File diff suppressed because it is too large Load Diff
+28 -7
View File
@@ -27,7 +27,6 @@ import (
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
@@ -39,10 +38,6 @@ var (
)
func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) {
if err := preflightTokenPersistence(p.configDir); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
}
// Use MCP mode if clientID is from MCP server
if IsClientIDFromMCP() {
return p.exchangeCodeViaMCP(ctx, code)
@@ -79,13 +74,21 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
// the currently configured client credentials. This is a convenience wrapper
// around OAuthProvider.exchangeCode for callers outside the auth package.
func ExchangeCodeForToken(ctx context.Context, configDir, code string) (*TokenData, error) {
if err := prepareLoginPersistence(configDir); err != nil {
return nil, fmt.Errorf("local login state cannot be safely updated before token exchange: %w", err)
}
p := &OAuthProvider{
configDir: configDir,
clientID: ClientID(),
Output: io.Discard,
httpClient: oauthHTTPClient,
}
return p.exchangeCode(ctx, code)
data, err := p.exchangeCode(ctx, code)
if err != nil {
return nil, err
}
data.FreshAuthorization = true
return data, nil
}
// exchangeCodeViaMCP exchanges auth code for token via MCP proxy.
@@ -290,6 +293,24 @@ func (p *OAuthProvider) parseTokenResponse(body []byte) (*TokenData, error) {
return data, nil
}
const legacyMCPRefreshRejectedCode = "invalidParameter.authCode.notFound"
// MCPTokenExchangeError preserves the backend business code so refresh callers
// can distinguish a legacy credential that requires a new authorization from
// transient transport failures.
type MCPTokenExchangeError struct {
Code string
Message string
}
func (e *MCPTokenExchangeError) Error() string {
return fmt.Sprintf("MCP token exchange failed: %s - %s", e.Code, e.Message)
}
func (e *MCPTokenExchangeError) requiresReauthorization() bool {
return strings.TrimSpace(e.Code) == legacyMCPRefreshRejectedCode
}
// parseMCPTokenResponse parses token response from MCP proxy.
// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "...", "corpName": "..."}
func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
@@ -313,7 +334,7 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
}
// Check for error response
if resp.ErrorCode != "" || resp.ErrorMsg != "" {
return nil, fmt.Errorf("MCP token exchange failed: %s - %s", resp.ErrorCode, resp.ErrorMsg)
return nil, &MCPTokenExchangeError{Code: resp.ErrorCode, Message: resp.ErrorMsg}
}
if resp.AccessToken == "" {
return nil, fmt.Errorf("MCP token response missing accessToken (body: %s)", string(body))
+48 -9
View File
@@ -24,6 +24,7 @@ import (
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
@@ -117,10 +118,13 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
if !force {
data, err := oauthLoadToken(p.configDir)
if err != nil && !errors.Is(err, ErrTokenDataNotFound) && !os.IsNotExist(err) {
if preflightErr := preflightTokenPersistence(p.configDir); preflightErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), preflightErr)
// A damaged selected slot must not make browser reauthorization
// impossible. The target identity is unknown until token exchange, so
// continue into the full flow and let the target-only preflight reject
// an unsafe overwrite after identity enrichment.
if p.logger != nil {
p.logger.Warn(i18n.T("读取现有登录态失败,将尝试扫码登录"), "error", err)
}
return nil, fmt.Errorf("load existing access token: %w", err)
}
if err == nil {
// Case 1: access_token still valid — no action needed.
@@ -150,7 +154,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
}
}
}
if err := preflightTokenPersistence(p.configDir); err != nil {
if err := prepareLoginPersistence(p.configDir); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
}
@@ -662,6 +666,7 @@ func (p *OAuthProvider) GetTokenSnapshot(ctx context.Context) (*TokenData, error
}
return nil, fmt.Errorf("load access token: %w", err)
}
profileSelector := StableTokenProfileSelector(p.configDir, data)
// Fast path: access_token still valid — no lock needed.
if data.IsAccessTokenValid() {
@@ -678,19 +683,40 @@ func (p *OAuthProvider) GetTokenSnapshot(ctx context.Context) (*TokenData, error
// refresh credential. Keep the profile active so a long-running source
// can retry after backoff. Terminal and unknown failures remain fatal.
if ClassifyRefreshFailure(rErr) != RefreshFailureTransient {
_ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired)
_ = oauthMarkProfile(p.configDir, profileSelector, ProfileStatusExpired)
}
if p.logger != nil {
p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr)
}
var exchangeErr *MCPTokenExchangeError
if errors.As(rErr, &exchangeErr) && exchangeErr.requiresReauthorization() {
return nil, fmt.Errorf(
"%s: %w",
legacyRefreshReauthorizationGuidance(profileSelector),
rErr,
)
}
return nil, fmt.Errorf("%s: %w", i18n.T("refresh_token 刷新失败"), rErr)
} else {
_ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired)
_ = oauthMarkProfile(p.configDir, profileSelector, ProfileStatusExpired)
}
return nil, fmt.Errorf("%s: %w", i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"), ErrTokenDataNotFound)
}
func legacyRefreshReauthorizationGuidance(profileSelector string) string {
guidance := "旧版登录态已无法由当前认证服务刷新;本地 profile 已保留,请重新运行 dws auth login 完成一次重新授权"
profileSelector = strings.TrimSpace(profileSelector)
if profileSelector == "" {
return guidance
}
return fmt.Sprintf(
"%s;为保留原身份,请把 --profile 参数设置为下方 profile 标识(标识仅作数据展示,不是可执行命令):\nprofile: %s",
guidance,
strconv.Quote(profileSelector),
)
}
// GetAccessToken returns a valid access token, auto-refreshing if needed.
// Uses a file lock with double-check pattern to prevent concurrent refresh
// from multiple CLI processes.
@@ -763,6 +789,9 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
// external host, exchanges it for tokens, and persists them.
func (p *OAuthProvider) ExchangeAuthCode(ctx context.Context, authCode, uid string) (*TokenData, error) {
if err := prepareLoginPersistence(p.configDir); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
}
tokenData, err := oauthExchange(p, ctx, authCode)
if err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
@@ -802,6 +831,9 @@ func (p *OAuthProvider) persistLoginToken(ctx context.Context, tokenData *TokenD
"user_id", strings.TrimSpace(tokenData.UserID),
"user_name", strings.TrimSpace(tokenData.UserName),
)
if err := preflightTokenWritePersistence(p.configDir, tokenData); err != nil {
return fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
}
if err := oauthSaveToken(p.configDir, tokenData); err != nil {
return err
}
@@ -812,14 +844,18 @@ func (p *OAuthProvider) prepareLoginToken(ctx context.Context, tokenData *TokenD
if tokenData == nil {
return fmt.Errorf("token data is empty")
}
tokenData.FreshAuthorization = true
if p != nil && p.IdentityEnricher != nil {
if err := p.IdentityEnricher(ctx, tokenData); err != nil {
return fmt.Errorf("resolve login identity: %w", err)
}
}
if strings.TrimSpace(tokenData.CorpID) != "" && strings.TrimSpace(tokenData.UserID) == "" {
return fmt.Errorf("resolve login identity: userId is required for corpId %q", tokenData.CorpID)
}
// v1.0.52 and earlier deliberately persisted the freshly exchanged token
// before best-effort contact enrichment. External-worker accounts can have a
// valid organization token while contact cannot return a userId, so rejecting
// that shape here makes an otherwise successful reauthorization impossible.
// SaveTokenData remains the safety boundary: an unresolved organization token
// cannot overwrite an organization that already has exact account identities.
return nil
}
@@ -830,6 +866,9 @@ func (p *OAuthProvider) persistKnownLoginToken(tokenData *TokenData) error {
if strings.TrimSpace(tokenData.CorpID) != "" && strings.TrimSpace(tokenData.UserID) == "" {
return fmt.Errorf("resolve login identity: userId is required for corpId %q", tokenData.CorpID)
}
if err := preflightTokenWritePersistence(p.configDir, tokenData); err != nil {
return fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
}
return oauthSaveToken(p.configDir, tokenData)
}
+74 -1
View File
@@ -303,6 +303,72 @@ func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) {
}
}
func TestOAuthForcedLoginIgnoresUnreadableUnrelatedProfile(t *testing.T) {
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
})
if err := SaveProfiles(f.configDir, &ProfilesConfig{
Version: profilesVersion,
Profiles: []Profile{{
Name: "unrelated",
CorpID: "corp-unrelated",
UserID: "user-unrelated",
}},
}); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
oldGet := authKeychainGet
oldValidate := authValidateEntries
t.Cleanup(func() {
authKeychainGet = oldGet
authValidateEntries = oldValidate
})
var unrelatedReads atomic.Int32
var inventoryCalls atomic.Int32
authKeychainGet = func(service, account string) (string, error) {
if account == TokenAccountForCorpID("corp-unrelated") ||
account == TokenAccountForIdentity("corp-unrelated", "user-unrelated") {
unrelatedReads.Add(1)
return "", errors.New("unrelated profile ciphertext is unreadable")
}
return oldGet(service, account)
}
authValidateEntries = func(string) error {
inventoryCalls.Add(1)
return errors.New("unrelated orphan ciphertext is unreadable")
}
loginDone := startOAuthLogin(t, context.Background(), f)
callbackDone := make(chan oauthHTTPResult, 1)
go func() {
callbackDone <- getHTTPBody(f.callbackBase + CallbackPath + "?code=unrelated-safe")
}()
waitOAuthSignal(t, f.exchangeEntered, loginDone, "token exchange")
closeOAuthRelease(f.exchangeRelease)
waitOAuthSignal(t, f.statusEntered, loginDone, "CLI auth status check")
closeOAuthRelease(f.statusRelease)
select {
case callback := <-callbackDone:
if callback.err != nil || !strings.Contains(callback.body, "<html") {
t.Fatalf("OAuth callback body = %q, %v", callback.body, callback.err)
}
case <-time.After(oauthTestWaitTimeout):
t.Fatal("timed out waiting for OAuth callback")
}
result := awaitOAuthLogin(t, loginDone)
if result.err != nil || result.token == nil || result.token.AccessToken != "access" {
t.Fatalf("OAuthProvider.Login() = %#v, %v", result.token, result.err)
}
if got := unrelatedReads.Load(); got != 0 {
t.Fatalf("unrelated profile token reads = %d, want 0", got)
}
if got := inventoryCalls.Load(); got != 0 {
t.Fatalf("full inventory validation calls = %d, want 0", got)
}
}
func TestCrossPlatformCoverageOAuthLoginMissingCallbackCode(t *testing.T) {
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
@@ -685,7 +751,10 @@ func TestCrossPlatformCoverageOAuthRefreshAndParsingEdges(t *testing.T) {
resetAppConfigCache()
oauthHTTPClient = mcpSrv.Client()
mcpProvider := &OAuthProvider{configDir: configDir, httpClient: mcpSrv.Client()}
mcpOriginal := &TokenData{ClientID: "mcp-client", Source: "mcp", RefreshToken: "refresh", CorpID: "corp"}
mcpOriginal := &TokenData{
ClientID: "mcp-client", Source: "mcp", RefreshToken: "refresh",
CorpID: "corp", UserID: "user",
}
if updated, err := mcpProvider.refreshViaMCP(context.Background(), mcpOriginal); err != nil || updated.Source != "mcp" {
t.Fatalf("MCP refresh = %#v, %v", updated, err)
}
@@ -1020,6 +1089,10 @@ func TestCrossPlatformCoverageOAuthHelperRemainingEdges(t *testing.T) {
if _, err := p.exchangeCode(context.Background(), "code"); !errors.Is(err, fail) {
t.Fatalf("direct exchange request error = %v", err)
}
oauthHTTPClient = networkClient
if _, err := ExchangeCodeForToken(context.Background(), p.configDir, "code"); !errors.Is(err, fail) {
t.Fatalf("exchange wrapper request error = %v", err)
}
p.httpClient = responseClient("{")
if _, err := p.exchangeCode(context.Background(), "code"); err == nil {
t.Fatal("malformed direct exchange succeeded")
@@ -0,0 +1,62 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"strconv"
"strings"
"testing"
)
func TestLegacyRefreshReauthorizationGuidanceTreatsProfileAsDisplayData(t *testing.T) {
tests := []struct {
name string
selector string
}{
{name: "command substitution", selector: `external-$(touch marker)`},
{name: "backticks", selector: "external-`touch marker`"},
{name: "newline", selector: "external\ndws auth reset"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
guidance := legacyRefreshReauthorizationGuidance(tt.selector)
if !strings.Contains(guidance, "dws auth login") ||
!strings.Contains(guidance, "--profile") ||
!strings.Contains(guidance, "profile 标识") {
t.Fatalf("guidance lacks stable reauthorization instructions: %q", guidance)
}
if strings.Contains(guidance, "dws auth login --profile") ||
strings.Contains(guidance, "--profile "+strconv.Quote(tt.selector)) {
t.Fatalf("guidance embeds untrusted selector in an executable command: %q", guidance)
}
if !strings.Contains(guidance, "profile: "+strconv.Quote(tt.selector)) {
t.Fatalf("guidance does not preserve selector as display data: %q", guidance)
}
if strings.Contains(tt.selector, "\n") && strings.Contains(guidance, tt.selector) {
t.Fatalf("guidance retained a raw selector newline: %q", guidance)
}
})
}
}
func TestLegacyRefreshReauthorizationGuidanceWithoutProfileStillExplainsLogin(t *testing.T) {
guidance := legacyRefreshReauthorizationGuidance("")
if !strings.Contains(guidance, "dws auth login") {
t.Fatalf("guidance = %q, want login instruction", guidance)
}
if strings.Contains(guidance, "--profile") || strings.Contains(guidance, "profile:") {
t.Fatalf("guidance = %q, should not invent an empty profile value", guidance)
}
}
+572 -68
View File
@@ -15,6 +15,7 @@ package auth
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -75,8 +76,11 @@ func withProfilesLock(configDir string, fn func() error) error {
}
const (
profilesJSONFile = "profiles.json"
profilesVersion = 2
profilesJSONFile = "profiles.json"
profilesVersion = 2
profilesUnresolvedSelectorVersion = 3
profilesMaxVersion = profilesUnresolvedSelectorVersion
unresolvedProfileSelectorPrefix = "@legacy/"
)
const (
@@ -169,6 +173,7 @@ func SaveProfiles(configDir string, cfg *ProfilesConfig) error {
return err
}
normalizeProfilesConfig(cfg)
normalizeProfilesVersionForSelectors(cfg)
if err := profilesMkdirAll(configDir, config.DirPerm); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
@@ -207,13 +212,16 @@ func ensureProfilesMigrationLocked(configDir string) error {
if err != nil {
return err
}
if cfg.Version > profilesVersion {
if cfg.Version > profilesMaxVersion {
return nil
}
if len(cfg.Profiles) == 0 {
// Version 2 with no profiles is an intentional logged-out tombstone.
// Never resurrect a stale legacy mirror after logout/reset.
if cfg.Version >= profilesVersion {
if normalizeProfilesVersionForSelectors(cfg) {
return profilesSave(configDir, cfg)
}
return nil
}
if !profilesTokenExists() {
@@ -240,6 +248,9 @@ func ensureProfilesMigrationLocked(configDir string) error {
cfg.OrgCurrentProfiles = make(map[string]string)
}
orgTokens := make(map[string]*TokenData)
var legacyToken *TokenData
var legacyTokenErr error
legacyTokenLoaded := false
for i := range cfg.Profiles {
p := &cfg.Profiles[i]
corpID := strings.TrimSpace(p.CorpID)
@@ -255,12 +266,75 @@ func ensureProfilesMigrationLocked(configDir string) error {
if loadErr != nil {
token = nil
}
var v2RepairProfile *Profile
if !legacySelectionState && errors.Is(loadErr, ErrTokenDataNotFound) {
v2RepairProfile = uniqueV2GlobalRepairProfile(cfg, corpID)
if v2RepairProfile != nil && strings.TrimSpace(v2RepairProfile.UserID) != "" {
_, identityErr := profilesLoadIdentity(corpID, v2RepairProfile.UserID)
switch {
case identityErr == nil:
// The exact identity is already usable. Do not let a stale
// global compatibility mirror recreate the organization slot.
v2RepairProfile = nil
case !errors.Is(identityErr, ErrTokenDataNotFound):
return identityErr
}
}
}
if (legacySelectionState || v2RepairProfile != nil) && errors.Is(loadErr, ErrTokenDataNotFound) {
// v1.0.50/1.0.51 installations can retain the selected
// organization only in the global compatibility slot. Consult
// that slot while migrating v1, or while repairing a non-empty
// v2 registry left half-migrated by an earlier CLI. The v2 path
// additionally requires one unambiguous profile, a missing exact
// slot when its userId is known, and a non-conflicting token userId.
// Persist the untouched organization mirror before identity
// enrichment below.
if !legacyTokenLoaded {
legacyToken, legacyTokenErr = profilesLoadLegacy()
legacyTokenLoaded = true
}
if legacyTokenErr != nil && !errors.Is(legacyTokenErr, ErrTokenDataNotFound) {
return legacyTokenErr
}
legacyMatchesProfile := legacySelectionState ||
legacyTokenMatchesV2RepairProfile(legacyToken, v2RepairProfile)
if legacyTokenErr == nil &&
legacyToken != nil &&
strings.TrimSpace(legacyToken.CorpID) == corpID &&
legacyMatchesProfile {
token = legacyToken
if err := profilesSaveCorp(corpID, token); err != nil {
return err
}
}
}
orgToken = token
orgTokens[corpID] = orgToken
}
if orgToken == nil {
continue
}
// v1.0.52 stored one token per organization. Some of those token blobs
// predate userId persistence even though profiles.json already recorded
// the account identity. Version 2 loads exact identities and therefore
// cannot safely use an organization mirror with no userId. A single
// profile with a known userId makes that association unambiguous,
// including when an earlier migration already bumped profiles.json to v2
// but failed before writing the identity slot. Enrich only the copy saved
// to that exact slot; never infer an identity for an organization with
// multiple accounts.
identityToken := orgToken
if strings.TrimSpace(orgToken.UserID) == "" &&
strings.TrimSpace(p.UserID) != "" &&
len(profilesForCorpID(cfg, corpID)) == 1 {
enriched := *orgToken
enriched.UserID = strings.TrimSpace(p.UserID)
if strings.TrimSpace(enriched.UserName) == "" {
enriched.UserName = strings.TrimSpace(p.UserName)
}
identityToken = &enriched
}
if strings.TrimSpace(p.UserID) == "" && strings.TrimSpace(orgToken.UserID) != "" {
if existing := findExactProfile(cfg, corpID, orgToken.UserID); existing != nil && existing != p {
p.CorpID = ""
@@ -273,12 +347,12 @@ func ensureProfilesMigrationLocked(configDir string) error {
}
changed = true
}
if strings.TrimSpace(p.UserID) == "" || strings.TrimSpace(orgToken.UserID) != strings.TrimSpace(p.UserID) {
if strings.TrimSpace(p.UserID) == "" || strings.TrimSpace(identityToken.UserID) != strings.TrimSpace(p.UserID) {
continue
}
_, identityErr := profilesLoadIdentity(corpID, p.UserID)
if errors.Is(identityErr, ErrTokenDataNotFound) {
if err := profilesSaveIdentity(corpID, p.UserID, orgToken); err != nil {
if err := profilesSaveIdentity(corpID, p.UserID, identityToken); err != nil {
return err
}
} else if identityErr != nil {
@@ -324,6 +398,10 @@ func ensureProfilesMigrationLocked(configDir string) error {
cfg.CurrentProfile = exact
changed = true
}
if exact := canonicalStoredSelector(cfg, cfg.PrimaryProfile); exact != "" && exact != cfg.PrimaryProfile {
cfg.PrimaryProfile = exact
changed = true
}
if legacySelectionState && cfg.CurrentProfile == "" {
if exact := canonicalStoredSelector(cfg, cfg.PrimaryProfile); exact != "" {
cfg.CurrentProfile = exact
@@ -345,12 +423,38 @@ func ensureProfilesMigrationLocked(configDir string) error {
cfg.Version = profilesVersion
changed = true
}
if normalizeProfilesVersionForSelectors(cfg) {
changed = true
}
if changed {
return profilesSave(configDir, cfg)
}
return nil
}
// uniqueV2GlobalRepairProfile returns the only profile that may safely be
// recovered from the legacy global token mirror. A v2 registry with multiple
// accounts in one organization is deliberately ineligible, even if one token
// happens to carry a matching userId: the global slot is a mutable compatibility
// mirror and is not authoritative account-selection state. A sole unresolved
// profile is eligible only for organization-slot repair; the token matcher below
// rejects any global token that tries to attach a userId to it.
func uniqueV2GlobalRepairProfile(cfg *ProfilesConfig, corpID string) *Profile {
profiles := profilesForCorpID(cfg, corpID)
if len(profiles) != 1 {
return nil
}
return profiles[0]
}
func legacyTokenMatchesV2RepairProfile(data *TokenData, profile *Profile) bool {
if data == nil || strings.TrimSpace(data.CorpID) != strings.TrimSpace(profile.CorpID) {
return false
}
tokenUserID := strings.TrimSpace(data.UserID)
return tokenUserID == "" || tokenUserID == strings.TrimSpace(profile.UserID)
}
// UpsertProfileFromToken updates profiles.json after a successful login or refresh.
func UpsertProfileFromToken(configDir string, data *TokenData) error {
return UpsertProfileFromTokenWithCurrent(configDir, data, true)
@@ -384,7 +488,9 @@ func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenDa
return err
}
normalizeProfilesConfig(cfg)
cfg.Version = profilesVersion
if cfg.Version < profilesVersion {
cfg.Version = profilesVersion
}
now := time.Now().Format(time.RFC3339)
userID := strings.TrimSpace(data.UserID)
var previousCurrent *Profile
@@ -392,7 +498,11 @@ func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenDa
previousCurrent, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
}
idx := profileIndexByIdentity(cfg, corpID, userID)
if idx < 0 && userID != "" {
if idx < 0 && userID != "" && len(profilesForCorpID(cfg, corpID)) == 1 {
// Upgrade an organization-scoped v1 profile only when it is the sole
// account in that organization. If exact identities already coexist
// with a blank profile, consuming the blank profile here would silently
// discard that unresolved historical account.
idx = legacyProfileIndexByCorpID(cfg, corpID)
}
if idx < 0 {
@@ -409,6 +519,7 @@ func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenDa
UpdatedAt: now,
}
cfg.Profiles = append(cfg.Profiles, profile)
idx = len(cfg.Profiles) - 1
} else {
p := &cfg.Profiles[idx]
if userID != "" {
@@ -431,17 +542,33 @@ func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenDa
p.LastUsedAt = now
p.UpdatedAt = now
}
storedProfile := &cfg.Profiles[idx]
if userID == "" && len(profilesForCorpID(cfg, corpID)) > 1 &&
(strings.TrimSpace(storedProfile.Name) == corpID || profileNameTakenByOtherIdentity(cfg, storedProfile.Name, corpID, "")) {
// A blank profile needs a stable name when exact identities coexist in
// the same organization; the corpId selector denotes the organization
// as a whole and is therefore not an exact account selector.
storedProfile.Name = chooseProfileName(cfg, data)
}
if makeCurrent {
newSelector := profileSelector(corpID, userID)
if previousCurrent != nil && ProfileSelector(*previousCurrent) != newSelector {
cfg.PreviousProfile = ProfileSelector(*previousCurrent)
newSelector := storedProfileSelector(cfg, storedProfile)
if previousCurrent != nil && storedProfileSelector(cfg, previousCurrent) != newSelector {
cfg.PreviousProfile = storedProfileSelector(cfg, previousCurrent)
}
cfg.CurrentProfile = newSelector
setOrgCurrentProfile(cfg, corpID, newSelector)
if userID == "" {
delete(cfg.OrgCurrentProfiles, corpID)
} else {
setOrgCurrentProfile(cfg, corpID, newSelector)
}
}
if cfg.CurrentProfile == "" {
cfg.CurrentProfile = profileSelector(corpID, userID)
setOrgCurrentProfile(cfg, corpID, cfg.CurrentProfile)
cfg.CurrentProfile = storedProfileSelector(cfg, storedProfile)
if userID == "" {
delete(cfg.OrgCurrentProfiles, corpID)
} else {
setOrgCurrentProfile(cfg, corpID, cfg.CurrentProfile)
}
}
return profilesSave(configDir, cfg)
}
@@ -452,6 +579,87 @@ func ProfileSelector(profile Profile) string {
return profileSelector(profile.CorpID, profile.UserID)
}
// storedProfileSelector returns a selector that remains exact inside
// profiles.json. A blank userId has only an organization selector in the
// public compatibility surface; when other accounts share that organization,
// use the profile's unique local name so current/previous pointers do not
// accidentally resolve to an exact account through OrgCurrentProfiles.
func storedProfileSelector(cfg *ProfilesConfig, profile *Profile) string {
if profile == nil {
return ""
}
if strings.TrimSpace(profile.UserID) != "" {
return ProfileSelector(*profile)
}
if cfg == nil {
return strings.TrimSpace(profile.CorpID)
}
if len(profilesForCorpID(cfg, profile.CorpID)) <= 1 {
return strings.TrimSpace(profile.CorpID)
}
name := strings.TrimSpace(profile.Name)
if localProfileSelectorIsSafe(cfg, profile, name) {
return name
}
return unresolvedProfileSelector(profile.CorpID)
}
// ProfileSelectionSelector returns the stable selector used for one profile.
// Exact identities use corpId:userId. A historical profile without userId
// keeps the organization selector while it is the only account, and otherwise
// uses either an unambiguous local name or a reserved, reversible selector.
func ProfileSelectionSelector(profile Profile, cfg *ProfilesConfig) string {
return storedProfileSelector(cfg, &profile)
}
func localProfileSelectorIsSafe(cfg *ProfilesConfig, profile *Profile, name string) bool {
if cfg == nil || profile == nil {
return false
}
name = strings.TrimSpace(name)
if name == "" || strings.Contains(name, ":") || strings.HasPrefix(name, unresolvedProfileSelectorPrefix) {
return false
}
nameMatches := 0
for i := range cfg.Profiles {
candidate := &cfg.Profiles[i]
if strings.TrimSpace(candidate.Name) == name {
nameMatches++
}
// Organization selectors are resolved before ordinary local names.
// Never persist a local selector that can be captured by that grammar.
if strings.TrimSpace(candidate.CorpID) == name || strings.TrimSpace(candidate.CorpName) == name {
return false
}
}
return nameMatches == 1
}
func unresolvedProfileSelector(corpID string) string {
corpID = strings.TrimSpace(corpID)
if corpID == "" {
return ""
}
return unresolvedProfileSelectorPrefix + base64.RawURLEncoding.EncodeToString([]byte(corpID))
}
func parseUnresolvedProfileSelector(selector string) (string, bool) {
selector = strings.TrimSpace(selector)
if !strings.HasPrefix(selector, unresolvedProfileSelectorPrefix) {
return "", false
}
encoded := strings.TrimPrefix(selector, unresolvedProfileSelectorPrefix)
decoded, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return "", false
}
corpID := strings.TrimSpace(string(decoded))
if corpID == "" || unresolvedProfileSelector(corpID) != selector {
return "", false
}
return corpID, true
}
// TokenProfileSelector returns the exact identity selector for token data when
// its userId is known, otherwise it returns the historical corpId selector.
func TokenProfileSelector(data *TokenData) string {
@@ -461,6 +669,34 @@ func TokenProfileSelector(data *TokenData) string {
return profileSelector(data.CorpID, data.UserID)
}
// StableTokenProfileSelector preserves the exact selector that loaded a token.
// This matters for an unresolved historical account sharing an organization
// with exact identities: reducing its selector to corpId would follow
// OrgCurrentProfiles and could mark or reauthorize a different account.
func StableTokenProfileSelector(configDir string, data *TokenData) string {
if selector := strings.TrimSpace(RuntimeProfile()); selector != "" {
return selector
}
fallback := TokenProfileSelector(data)
if data == nil {
return fallback
}
cfg, err := LoadProfiles(configDir)
if err != nil || cfg == nil || strings.TrimSpace(cfg.CurrentProfile) == "" {
return fallback
}
selector := canonicalStoredSelector(cfg, cfg.CurrentProfile)
if selector == "" {
selector = strings.TrimSpace(cfg.CurrentProfile)
}
profile, _, err := resolveProfileSelection(configDir, cfg, selector)
if err != nil || profile == nil ||
!sameProfileIdentity(profile.CorpID, profile.UserID, data.CorpID, data.UserID) {
return fallback
}
return storedProfileSelector(cfg, profile)
}
func profileSelector(corpID, userID string) string {
corpID = strings.TrimSpace(corpID)
userID = strings.TrimSpace(userID)
@@ -609,7 +845,13 @@ func setCurrentProfileLocked(configDir, selector string) (*Profile, error) {
return nil, err
}
originalCfg := cloneProfilesConfig(cfg)
mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID)
syncOrganization := shouldSyncOrganizationMirror(cfg, *p)
if !syncOrganization {
if err := validateIdentityOnlyProfileToken(*p); err != nil {
return nil, err
}
}
mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID, syncOrganization)
if err != nil {
return nil, err
}
@@ -617,20 +859,26 @@ func setCurrentProfileLocked(configDir, selector string) (*Profile, error) {
if strings.TrimSpace(cfg.CurrentProfile) != "" {
previousCurrent, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
}
storedSelector := ProfileSelector(*p)
storedSelector := storedProfileSelector(cfg, p)
if cfg.CurrentProfile != storedSelector {
if previousCurrent != nil {
cfg.PreviousProfile = ProfileSelector(*previousCurrent)
cfg.PreviousProfile = storedProfileSelector(cfg, previousCurrent)
}
cfg.CurrentProfile = storedSelector
}
setOrgCurrentProfile(cfg, p.CorpID, storedSelector)
if strings.TrimSpace(p.UserID) == "" {
delete(cfg.OrgCurrentProfiles, strings.TrimSpace(p.CorpID))
} else {
setOrgCurrentProfile(cfg, p.CorpID, storedSelector)
}
touchProfileUsage(p)
if err := profilesSave(configDir, cfg); err != nil {
return nil, err
}
if err := syncOrganizationTokenMirrorForProfile(*p); err != nil {
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
if syncOrganization {
if err := syncOrganizationTokenMirrorForProfile(*p); err != nil {
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
}
}
if err := profilesSyncLegacyMirror(configDir); err != nil {
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
@@ -669,7 +917,13 @@ func usePreviousProfileLocked(configDir string) (*Profile, error) {
return nil, fmt.Errorf("resolve previous profile %q: %w", prev, err)
}
originalCfg := cloneProfilesConfig(cfg)
mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID)
syncOrganization := shouldSyncOrganizationMirror(cfg, *p)
if !syncOrganization {
if err := validateIdentityOnlyProfileToken(*p); err != nil {
return nil, err
}
}
mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID, syncOrganization)
if err != nil {
return nil, err
}
@@ -677,19 +931,25 @@ func usePreviousProfileLocked(configDir string) (*Profile, error) {
if strings.TrimSpace(cfg.CurrentProfile) != "" {
current, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
}
cfg.CurrentProfile = ProfileSelector(*p)
cfg.CurrentProfile = storedProfileSelector(cfg, p)
if current != nil {
cfg.PreviousProfile = ProfileSelector(*current)
cfg.PreviousProfile = storedProfileSelector(cfg, current)
} else {
cfg.PreviousProfile = ""
}
setOrgCurrentProfile(cfg, p.CorpID, ProfileSelector(*p))
if strings.TrimSpace(p.UserID) == "" {
delete(cfg.OrgCurrentProfiles, strings.TrimSpace(p.CorpID))
} else {
setOrgCurrentProfile(cfg, p.CorpID, ProfileSelector(*p))
}
touchProfileUsage(p)
if err := profilesSave(configDir, cfg); err != nil {
return nil, err
}
if err := syncOrganizationTokenMirrorForProfile(*p); err != nil {
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
if syncOrganization {
if err := syncOrganizationTokenMirrorForProfile(*p); err != nil {
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
}
}
if err := profilesSyncLegacyMirror(configDir); err != nil {
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
@@ -733,6 +993,21 @@ func removeProfileLocked(configDir, selector string) (*Profile, error) {
return nil, err
}
removed := *p
originalCurrentSelector := strings.TrimSpace(cfg.CurrentProfile)
originalOrganizationCurrent := strings.TrimSpace(cfg.OrgCurrentProfiles[strings.TrimSpace(removed.CorpID)])
pointers := []*string{&cfg.PrimaryProfile, &cfg.CurrentProfile, &cfg.PreviousProfile}
pointerMatches := make([]bool, len(pointers))
for i, pointer := range pointers {
selected, _, resolveErr := resolveProfileSelection(configDir, cfg, *pointer)
if resolveErr == nil && selected != nil {
if exact {
pointerMatches[i] =
sameProfileIdentity(selected.CorpID, selected.UserID, removed.CorpID, removed.UserID)
} else {
pointerMatches[i] = strings.TrimSpace(selected.CorpID) == strings.TrimSpace(removed.CorpID)
}
}
}
kept := cfg.Profiles[:0]
for _, profile := range cfg.Profiles {
remove := profile.CorpID == removed.CorpID
@@ -748,7 +1023,7 @@ func removeProfileLocked(configDir, selector string) (*Profile, error) {
if exact && len(profilesForCorpID(cfg, removed.CorpID)) > 0 {
remaining := profilesForCorpID(cfg, removed.CorpID)
if len(remaining) == 1 {
replacementSelector = ProfileSelector(*remaining[0])
replacementSelector = storedProfileSelector(cfg, remaining[0])
}
}
if exact {
@@ -762,15 +1037,14 @@ func removeProfileLocked(configDir, selector string) (*Profile, error) {
} else {
delete(cfg.OrgCurrentProfiles, removed.CorpID)
}
pointers := []*string{&cfg.PrimaryProfile, &cfg.CurrentProfile, &cfg.PreviousProfile}
for _, pointer := range pointers {
for i, pointer := range pointers {
if exact {
if selectorMatchesIdentity(*pointer, removed) {
if pointerMatches[i] {
*pointer = replacementSelector
}
continue
}
if selectorTargetsCorp(*pointer, removed.CorpID) {
if pointerMatches[i] || selectorTargetsCorp(*pointer, removed.CorpID) {
*pointer = ""
}
}
@@ -779,7 +1053,35 @@ func removeProfileLocked(configDir, selector string) (*Profile, error) {
cfg.CurrentProfile = previous
cfg.PreviousProfile = ""
} else if len(cfg.Profiles) == 1 {
cfg.CurrentProfile = ProfileSelector(cfg.Profiles[0])
cfg.CurrentProfile = storedProfileSelector(cfg, &cfg.Profiles[0])
}
}
if cfg.PreviousProfile != "" && cfg.PreviousProfile == cfg.CurrentProfile {
cfg.PreviousProfile = ""
}
currentSelectionChanged := strings.TrimSpace(cfg.CurrentProfile) != originalCurrentSelector
organizationCurrentChanged := strings.TrimSpace(cfg.OrgCurrentProfiles[strings.TrimSpace(removed.CorpID)]) != originalOrganizationCurrent
if strings.TrimSpace(cfg.CurrentProfile) != "" {
if current, _, resolveErr := resolveProfileSelection(configDir, cfg, cfg.CurrentProfile); resolveErr == nil && current != nil {
if (currentSelectionChanged || organizationCurrentChanged) &&
strings.TrimSpace(current.CorpID) == strings.TrimSpace(removed.CorpID) &&
unresolvedProfileForCorp(cfg, removed.CorpID) != nil {
if strings.TrimSpace(current.UserID) == "" {
delete(cfg.OrgCurrentProfiles, strings.TrimSpace(current.CorpID))
} else {
setOrgCurrentProfile(cfg, current.CorpID, storedProfileSelector(cfg, current))
}
}
}
}
// Removing the exact identity that forced a blank sibling to use the v3
// reserved selector can make that blank profile the sole account in its
// organization. Re-canonicalize every surviving pointer against the final
// profile set before SaveProfiles derives the schema version, so the pointer
// collapses back to the v2 corpId grammar instead of pinning the file at v3.
for _, pointer := range pointers {
if canonical := canonicalStoredSelector(cfg, *pointer); canonical != "" {
*pointer = canonical
}
}
if cfg.PreviousProfile != "" && cfg.PreviousProfile == cfg.CurrentProfile {
@@ -808,6 +1110,9 @@ func selectorTargetsCorp(selector, corpID string) bool {
if selector == corpID {
return true
}
if selectedCorpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
return selectedCorpID == corpID
}
selectedCorpID, _, exact := ParseIdentitySelector(selector)
return exact && selectedCorpID == corpID
}
@@ -840,28 +1145,72 @@ func markProfileStatusLocked(configDir, selector, status string) error {
}
func ensureProfilesWritable(cfg *ProfilesConfig) error {
if cfg != nil && cfg.Version > profilesVersion {
if cfg != nil && cfg.Version > profilesMaxVersion {
return fmt.Errorf(
"profiles.json version %d is newer than supported version %d; upgrade dws before changing profiles",
cfg.Version,
profilesVersion,
profilesMaxVersion,
)
}
return nil
}
// normalizeProfilesVersionForSelectors derives the persisted schema version
// from the final normalized selector grammar. Keep v3 only while a legal
// reserved unresolved-identity selector remains on disk; once completion,
// deletion, or canonicalization removes that grammar, the file is again safe
// for v2 clients and should downgrade to v2.
func normalizeProfilesVersionForSelectors(cfg *ProfilesConfig) bool {
if cfg == nil || cfg.Version > profilesMaxVersion {
return false
}
target := cfg.Version
if profilesConfigContainsUnresolvedSelector(cfg) {
target = profilesUnresolvedSelectorVersion
} else if cfg.Version >= profilesVersion {
target = profilesVersion
}
if target == cfg.Version {
return false
}
cfg.Version = target
return true
}
func profilesConfigContainsUnresolvedSelector(cfg *ProfilesConfig) bool {
if cfg == nil {
return false
}
for _, selector := range []string{cfg.PrimaryProfile, cfg.CurrentProfile, cfg.PreviousProfile} {
if _, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
return true
}
}
for _, selector := range cfg.OrgCurrentProfiles {
if _, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
return true
}
}
return false
}
type profileSelectionMirrorSnapshot struct {
organization tokenSlotSnapshot
legacy tokenSlotSnapshot
marker tokenMarkerSnapshot
}
func snapshotProfileSelectionMirrors(configDir, corpID string) (profileSelectionMirrorSnapshot, error) {
organization, err := snapshotTokenSlot(func() (*TokenData, error) {
return profilesLoadCorp(corpID)
})
if err != nil {
return profileSelectionMirrorSnapshot{}, err
func snapshotProfileSelectionMirrors(configDir, corpID string, includeOrganization bool) (profileSelectionMirrorSnapshot, error) {
var organization tokenSlotSnapshot
if includeOrganization {
var err error
organization, err = snapshotTokenSlot(func() (*TokenData, error) {
return profilesLoadCorp(corpID)
})
if err != nil {
return profileSelectionMirrorSnapshot{}, err
}
}
legacy, err := snapshotTokenSlot(profilesLoadLegacy)
if err != nil {
@@ -889,12 +1238,14 @@ func rollbackProfileSelection(
if err := profilesSave(configDir, cloneProfilesConfig(cfg)); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
if mirrors.organization.exists {
if err := profilesSaveCorp(corpID, mirrors.organization.token); err != nil {
if mirrors.organization.known {
if mirrors.organization.exists {
if err := profilesSaveCorp(corpID, mirrors.organization.token); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
} else if err := profilesDeleteCorp(corpID); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
} else if err := profilesDeleteCorp(corpID); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
if mirrors.legacy.exists {
if err := profilesSaveLegacy(mirrors.legacy.token); err != nil {
@@ -975,33 +1326,48 @@ func syncOrganizationTokenMirrorForProfile(profile Profile) error {
}
func loadTokenForProfileIdentity(profile Profile) (*TokenData, error) {
if strings.TrimSpace(profile.UserID) != "" {
data, err := profilesLoadIdentity(profile.CorpID, profile.UserID)
if err == nil {
return data, nil
}
if !errors.Is(err, ErrTokenDataNotFound) {
if strings.TrimSpace(profile.UserID) == "" {
data, err := profilesLoadCorp(profile.CorpID)
if err != nil {
return nil, err
}
orgData, orgErr := profilesLoadCorp(profile.CorpID)
if orgErr != nil {
if errors.Is(orgErr, ErrTokenDataNotFound) {
return nil, err
}
return nil, orgErr
if data == nil {
return nil, ErrTokenDataNotFound
}
if strings.TrimSpace(orgData.UserID) == "" {
return nil, fmt.Errorf("organization token mirror for corpId %q has no userId; cannot use it for profile %q", profile.CorpID, ProfileSelector(profile))
if strings.TrimSpace(data.UserID) != "" {
return nil, fmt.Errorf(
"organization token mirror for corpId %q belongs to userId %q; cannot use it for unresolved profile %q",
profile.CorpID,
data.UserID,
profile.Name,
)
}
if strings.TrimSpace(orgData.UserID) != strings.TrimSpace(profile.UserID) {
return nil, err
}
if saveErr := profilesSaveIdentity(profile.CorpID, profile.UserID, orgData); saveErr != nil {
return nil, saveErr
}
return orgData, nil
return data, nil
}
return profilesLoadCorp(profile.CorpID)
data, err := profilesLoadIdentity(profile.CorpID, profile.UserID)
if err == nil {
return data, nil
}
if !errors.Is(err, ErrTokenDataNotFound) {
return nil, err
}
orgData, orgErr := profilesLoadCorp(profile.CorpID)
if orgErr != nil {
if errors.Is(orgErr, ErrTokenDataNotFound) {
return nil, err
}
return nil, orgErr
}
if strings.TrimSpace(orgData.UserID) == "" {
return nil, fmt.Errorf("organization token mirror for corpId %q has no userId; cannot use it for profile %q", profile.CorpID, ProfileSelector(profile))
}
if strings.TrimSpace(orgData.UserID) != strings.TrimSpace(profile.UserID) {
return nil, err
}
if saveErr := profilesSaveIdentity(profile.CorpID, profile.UserID, orgData); saveErr != nil {
return nil, saveErr
}
return orgData, nil
}
func normalizeProfilesConfig(cfg *ProfilesConfig) {
@@ -1127,6 +1493,12 @@ func resolveProfileSelection(_ string, cfg *ProfilesConfig, selector string) (*P
if selector == "" {
return nil, false, fmt.Errorf("profile selector is empty")
}
if corpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
if profile := unresolvedProfileForCorp(cfg, corpID); profile != nil {
return profile, true, nil
}
return nil, true, fmt.Errorf("historical profile for organization %q not found", corpID)
}
if organization, account, compound := ParseIdentitySelector(selector); compound {
corpID, err := resolveOrganizationCorpID(cfg, organization)
@@ -1218,6 +1590,12 @@ func resolveProfileDeletionSelection(cfg *ProfilesConfig, selector string) (*Pro
if selector == "" {
return nil, false, fmt.Errorf("profile selector is empty")
}
if corpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
if profile := unresolvedProfileForCorp(cfg, corpID); profile != nil {
return profile, true, nil
}
return nil, true, fmt.Errorf("historical profile for organization %q not found", corpID)
}
if _, _, compound := ParseIdentitySelector(selector); compound {
return resolveProfileSelection("", cfg, selector)
}
@@ -1303,6 +1681,12 @@ func resolveOrganizationDefault(cfg *ProfilesConfig, corpID, displaySelector str
return p, false, nil
}
}
if unresolved := unresolvedProfileForCorp(cfg, corpID); unresolved != nil {
// With no exact organization-current selection, the organization slot
// belongs to the sole unresolved historical account. Do not choose an
// arbitrary exact identity merely because it shares the corpId.
return unresolved, false, nil
}
if len(profiles) == 1 {
return profiles[0], false, nil
}
@@ -1314,12 +1698,18 @@ func resolveOrganizationDefault(cfg *ProfilesConfig, corpID, displaySelector str
}
func profileSelectorCandidates(profiles []*Profile) []string {
cfg := &ProfilesConfig{Profiles: make([]Profile, 0, len(profiles))}
for _, profile := range profiles {
if profile != nil {
cfg.Profiles = append(cfg.Profiles, *profile)
}
}
candidates := make([]string, 0, len(profiles))
for _, p := range profiles {
if p == nil {
continue
}
candidates = append(candidates, ProfileSelector(*p))
candidates = append(candidates, storedProfileSelector(cfg, p))
}
sort.Strings(candidates)
return candidates
@@ -1341,18 +1731,39 @@ func canonicalStoredSelector(cfg *ProfilesConfig, selector string) string {
if selector == "" {
return ""
}
if corpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
if profile := unresolvedProfileForCorp(cfg, corpID); profile != nil {
return storedProfileSelector(cfg, profile)
}
return ""
}
if corpID, userID, exact := ParseIdentitySelector(selector); exact {
if p := findExactProfile(cfg, corpID, userID); p != nil {
return ProfileSelector(*p)
}
// A colon-containing legacy local name is recoverable only when it does
// not name a real exact identity. Exact corpId:userId always wins.
if profile := unresolvedProfileForLocalName(cfg, selector); profile != nil {
return storedProfileSelector(cfg, profile)
}
return ""
}
// Older multi-account writers stored the unresolved profile's local name.
// Recover it only when no profile gives the same text organization-selector
// meaning. CorpId and CorpName have always outranked local names in the
// public resolver; migration must preserve that precedence instead of
// silently redirecting one organization's selector to another blank profile.
if !selectorConflictsWithOrganizationGrammar(cfg, selector) {
if profile := unresolvedProfileForLocalName(cfg, selector); profile != nil {
return storedProfileSelector(cfg, profile)
}
}
if profiles := profilesForCorpID(cfg, selector); len(profiles) > 0 {
if exact := exactProfileSelectorForCorp(cfg, selector, cfg.OrgCurrentProfiles[selector]); exact != "" {
return exact
}
if len(profiles) == 1 {
return ProfileSelector(*profiles[0])
return storedProfileSelector(cfg, profiles[0])
}
return ""
}
@@ -1360,7 +1771,21 @@ func canonicalStoredSelector(cfg *ProfilesConfig, selector string) string {
if err != nil || p == nil {
return ""
}
return ProfileSelector(*p)
return storedProfileSelector(cfg, p)
}
func selectorConflictsWithOrganizationGrammar(cfg *ProfilesConfig, selector string) bool {
if cfg == nil {
return false
}
selector = strings.TrimSpace(selector)
for i := range cfg.Profiles {
if strings.TrimSpace(cfg.Profiles[i].CorpID) == selector ||
strings.TrimSpace(cfg.Profiles[i].CorpName) == selector {
return true
}
}
return false
}
func setOrgCurrentProfile(cfg *ProfilesConfig, corpID, selector string) {
@@ -1445,6 +1870,79 @@ func profilesForCorpID(cfg *ProfilesConfig, corpID string) []*Profile {
return result
}
func unresolvedProfileForCorp(cfg *ProfilesConfig, corpID string) *Profile {
if cfg == nil {
return nil
}
corpID = strings.TrimSpace(corpID)
for i := range cfg.Profiles {
profile := &cfg.Profiles[i]
if strings.TrimSpace(profile.CorpID) == corpID && strings.TrimSpace(profile.UserID) == "" {
return profile
}
}
return nil
}
func unresolvedProfileForLocalName(cfg *ProfilesConfig, name string) *Profile {
if cfg == nil {
return nil
}
name = strings.TrimSpace(name)
if name == "" {
return nil
}
var match *Profile
for i := range cfg.Profiles {
profile := &cfg.Profiles[i]
if strings.TrimSpace(profile.UserID) != "" || strings.TrimSpace(profile.Name) != name ||
len(profilesForCorpID(cfg, profile.CorpID)) <= 1 {
continue
}
if match != nil {
return nil
}
match = profile
}
return match
}
// When a blank profile coexists with exact accounts, the organization slot is
// that unresolved profile's only canonical credential. Exact identities must
// remain in their identity slots and may still become global current without
// overwriting the organization slot.
func shouldSyncOrganizationMirror(cfg *ProfilesConfig, profile Profile) bool {
return strings.TrimSpace(profile.UserID) == "" || unresolvedProfileForCorp(cfg, profile.CorpID) == nil
}
// validateIdentityOnlyProfileToken verifies the canonical token slot before a
// profile selection is persisted. This path is used only when an unresolved
// profile owns the organization slot, so an exact identity must not fall back
// to that slot. It is deliberately read-only: a rejected switch leaves every
// selection pointer and compatibility mirror untouched.
func validateIdentityOnlyProfileToken(profile Profile) error {
corpID := strings.TrimSpace(profile.CorpID)
userID := strings.TrimSpace(profile.UserID)
if corpID == "" || userID == "" {
return ErrTokenDataNotFound
}
data, err := profilesLoadIdentity(corpID, userID)
if err != nil {
return fmt.Errorf("load token for profile %q: %w", ProfileSelector(profile), err)
}
if data == nil {
return fmt.Errorf("load token for profile %q: %w", ProfileSelector(profile), ErrTokenDataNotFound)
}
if !sameProfileIdentity(data.CorpID, data.UserID, corpID, userID) {
return fmt.Errorf(
"token in profile slot %q belongs to %q; identity does not match selected profile",
ProfileSelector(profile),
profileSelector(data.CorpID, data.UserID),
)
}
return nil
}
func profileSelectorReferenceExists(cfg *ProfilesConfig, selector string) bool {
if cfg == nil {
return false
@@ -1453,6 +1951,12 @@ func profileSelectorReferenceExists(cfg *ProfilesConfig, selector string) bool {
if selector == "" {
return false
}
if corpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
return unresolvedProfileForCorp(cfg, corpID) != nil
}
if unresolvedProfileForLocalName(cfg, selector) != nil {
return true
}
if corpID, userID, exact := ParseIdentitySelector(selector); exact {
if findExactProfile(cfg, corpID, userID) != nil {
return true
+100
View File
@@ -10,6 +10,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
@@ -152,4 +153,103 @@ func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientR
if markCalls != 1 {
t.Fatalf("terminal refresh marked profile expired %d times, want 1", markCalls)
}
oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
return nil, &MCPTokenExchangeError{
Code: legacyMCPRefreshRejectedCode,
Message: "不合法的临时授权码",
}
}
_, err := provider.GetTokenSnapshot(context.Background())
if err == nil || !strings.Contains(err.Error(), "dws auth login") ||
!strings.Contains(err.Error(), "--profile") ||
!strings.Contains(err.Error(), `profile: "corp:user"`) ||
strings.Contains(err.Error(), `dws auth login --profile "corp:user"`) ||
!strings.Contains(err.Error(), legacyMCPRefreshRejectedCode) {
t.Fatalf("legacy MCP refresh guidance = %v", err)
}
var exchangeErr *MCPTokenExchangeError
if !errors.As(err, &exchangeErr) || !exchangeErr.requiresReauthorization() {
t.Fatalf("legacy MCP refresh cause was not preserved: %v", err)
}
if markCalls != 2 {
t.Fatalf("legacy MCP rejection marked profile expired %d times, want 2", markCalls)
}
SetRuntimeProfile("External Worker")
_, err = provider.GetTokenSnapshot(context.Background())
SetRuntimeProfile("")
if err == nil || !strings.Contains(err.Error(), "dws auth login") ||
!strings.Contains(err.Error(), `profile: "External Worker"`) ||
strings.Contains(err.Error(), `dws auth login --profile "External Worker"`) {
t.Fatalf("legacy MCP refresh guidance did not isolate spaced selector as display data: %v", err)
}
if markCalls != 3 {
t.Fatalf("spaced legacy MCP rejection marked profile expired %d times, want 3", markCalls)
}
}
func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsolated(t *testing.T) {
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
expired := *fixture.blankToken
expired.ExpiresAt = time.Now().Add(-time.Hour)
expired.RefreshExpAt = time.Now().Add(time.Hour)
oldLoad := oauthLoadToken
oldLoadLocked := oauthLoadTokenLocked
oldAcquire := oauthAcquireLock
oldRefresh := oauthRefreshToken
oldMark := oauthMarkProfile
oldEdition := edition.Get()
t.Cleanup(func() {
oauthLoadToken = oldLoad
oauthLoadTokenLocked = oldLoadLocked
oauthAcquireLock = oldAcquire
oauthRefreshToken = oldRefresh
oauthMarkProfile = oldMark
edition.Override(oldEdition)
})
edition.Override(&edition.Hooks{})
oauthLoadToken = func(string) (*TokenData, error) { return &expired, nil }
oauthLoadTokenLocked = func(string, string) (*TokenData, error) { return &expired, nil }
oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }
oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
return nil, &MCPTokenExchangeError{
Code: legacyMCPRefreshRejectedCode,
Message: "legacy refresh rejected",
}
}
var markedSelector string
oauthMarkProfile = func(configDir, selector, status string) error {
markedSelector = selector
return MarkProfileStatus(configDir, selector, status)
}
provider := NewOAuthProvider(fixture.configDir, nil)
_, err := provider.GetTokenSnapshot(context.Background())
if err == nil || !strings.Contains(err.Error(), "dws auth login") ||
!strings.Contains(err.Error(), "--profile") ||
!strings.Contains(err.Error(), "profile: "+strconv.Quote(fixture.blankSelector)) ||
strings.Contains(err.Error(), "dws auth login --profile") {
t.Fatalf("legacy blank refresh guidance = %v, want selector %q", err, fixture.blankSelector)
}
if markedSelector != fixture.blankSelector {
t.Fatalf("marked selector = %q, want blank %q", markedSelector, fixture.blankSelector)
}
cfg, loadErr := LoadProfiles(fixture.configDir)
if loadErr != nil {
t.Fatalf("LoadProfiles() error = %v", loadErr)
}
for _, profile := range cfg.Profiles {
switch profile.UserID {
case "":
if profile.Status != ProfileStatusExpired {
t.Fatalf("blank profile status = %q, want expired", profile.Status)
}
case fixture.exactUserID:
if profile.Status != ProfileStatusActive {
t.Fatalf("exact profile status = %q, want active", profile.Status)
}
}
}
}
+6
View File
@@ -43,6 +43,9 @@ func TestCrossPlatformCoverageTokenPersistencePreflightRemainingEdges(t *testing
})
edition.Override(&edition.Hooks{SaveToken: func(string, []byte) error { return nil }})
if err := prepareLoginPersistence(t.TempDir()); err != nil {
t.Fatal(err)
}
if err := preflightTokenPersistence(t.TempDir()); err != nil {
t.Fatal(err)
}
@@ -55,6 +58,9 @@ func TestCrossPlatformCoverageTokenPersistencePreflightRemainingEdges(t *testing
authValidateEntries = func(string) error { return nil }
profileFail := errors.New("profile load failed")
profilesReadFile = func(string) ([]byte, error) { return nil, profileFail }
if err := prepareLoginPersistence(t.TempDir()); !errors.Is(err, profileFail) {
t.Fatalf("schema preflight profile load error = %v", err)
}
if err := preflightTokenPersistence(t.TempDir()); !errors.Is(err, profileFail) {
t.Fatalf("profile load error = %v", err)
}
+313 -75
View File
@@ -59,27 +59,28 @@ var (
profile, _, err := resolveProfileForLoadLocked(configDir, selector)
return profile, err
}
tokenResolveDeletion = resolveProfileDeletionSelection
tokenResolveSelection = resolveProfileSelection
tokenUpsertProfile = upsertProfileFromTokenWithCurrentLocked
tokenRemoveProfile = removeProfileLocked
tokenSyncLegacyMirror = syncLegacyTokenMirrorLocked
tokenSyncOrganizationMirror = syncOrganizationTokenMirrorForProfile
tokenLoadProfiles = LoadProfiles
tokenSaveProfiles = SaveProfiles
tokenWriteMarker = WriteTokenMarker
tokenWriteManualMarker = WriteManualTokenMarker
tokenDeleteMarker = DeleteTokenMarker
tokenParseURL = url.Parse
tokenNewRequest = http.NewRequestWithContext
tokenDefaultConfigDir = getDefaultConfigDir
tokenLoadData = LoadTokenData
tokenRevokeURL = GetRevokeTokenURL
tokenMCPBaseURL = GetMCPBaseURL
tokenLogoutURL = LogoutURL
tokenLogoutContinueURL = LogoutContinueURL
tokenLogoutHTTPClient = &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
tokenRevokeHTTPClient = &http.Client{Timeout: 10 * time.Second}
tokenResolveDeletion = resolveProfileDeletionSelection
tokenResolveSelection = resolveProfileSelection
tokenUpsertProfile = upsertProfileFromTokenWithCurrentLocked
tokenRemoveProfile = removeProfileLocked
tokenSyncLegacyMirror = syncLegacyTokenMirrorLocked
tokenSyncOrganizationMirror = syncOrganizationTokenMirrorForProfile
tokenLoadProfiles = LoadProfiles
tokenEnsureProfilesMigration = ensureProfilesMigrationLocked
tokenSaveProfiles = SaveProfiles
tokenWriteMarker = WriteTokenMarker
tokenWriteManualMarker = WriteManualTokenMarker
tokenDeleteMarker = DeleteTokenMarker
tokenParseURL = url.Parse
tokenNewRequest = http.NewRequestWithContext
tokenDefaultConfigDir = getDefaultConfigDir
tokenLoadData = LoadTokenData
tokenRevokeURL = GetRevokeTokenURL
tokenMCPBaseURL = GetMCPBaseURL
tokenLogoutURL = LogoutURL
tokenLogoutContinueURL = LogoutContinueURL
tokenLogoutHTTPClient = &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
tokenRevokeHTTPClient = &http.Client{Timeout: 10 * time.Second}
)
// TokenData holds the OAuth token set persisted to disk.
@@ -96,6 +97,96 @@ type TokenData struct {
ClientID string `json:"client_id,omitempty"` // Associated app client ID for refresh
UpdatedAt string `json:"updated_at,omitempty"`
Source string `json:"source,omitempty"`
// LegacyOrgScopedProfile is an in-memory destination for an explicitly
// matched historical profile whose userId was never resolved. It is never
// persisted as token material.
LegacyOrgScopedProfile string `json:"-"`
// FreshAuthorization distinguishes a new OAuth/device exchange from a
// refresh of the credential already selected locally. Persistence uses this
// transient marker to reject ambiguous UID-less logins without breaking
// legitimate refreshes of unresolved accounts.
FreshAuthorization bool `json:"-"`
}
// tokenPersistenceWritePlan is the single source of truth for deciding which
// credential slots a token publication can touch. Both the write path and its
// read-only preflight must use this plan so a slot cannot be newly written
// without first being checked for unreadable data.
//
// The plan is intentionally pure: callers supply the already-loaded profile
// registry and process-local runtime selector, and no storage is read or
// mutated while the decision is made.
type tokenPersistenceWritePlan struct {
CorpID string
UserID string
RuntimeSelector string
PersistenceSelector string
ExactSelector string
MakeCurrent bool
ExistingIdentity bool
UpgradesLegacyProfile bool
PreserveUnresolvedOrganization bool
WriteIdentity bool
WriteOrganization bool
WriteGlobal bool
}
func planTokenPersistenceWrites(
cfg *ProfilesConfig,
data *TokenData,
runtimeSelector string,
) tokenPersistenceWritePlan {
plan := tokenPersistenceWritePlan{
RuntimeSelector: strings.TrimSpace(runtimeSelector),
// Manual and organization-bound publications both snapshot the global
// compatibility slot before they can replace or resynchronize it.
WriteGlobal: true,
}
if data == nil {
return plan
}
plan.CorpID = strings.TrimSpace(data.CorpID)
plan.UserID = strings.TrimSpace(data.UserID)
if plan.CorpID == "" {
return plan
}
plan.PersistenceSelector = plan.RuntimeSelector
if plan.PersistenceSelector == "" && plan.UserID == "" {
plan.PersistenceSelector = strings.TrimSpace(data.LegacyOrgScopedProfile)
}
plan.MakeCurrent = plan.PersistenceSelector == ""
plan.ExactSelector = profileSelector(plan.CorpID, plan.UserID)
plan.ExistingIdentity = profileIndexByIdentity(cfg, plan.CorpID, plan.UserID) >= 0
plan.UpgradesLegacyProfile = !plan.ExistingIdentity &&
plan.UserID != "" &&
len(profilesForCorpID(cfg, plan.CorpID)) == 1 &&
legacyProfileIndexByCorpID(cfg, plan.CorpID) >= 0
plan.PreserveUnresolvedOrganization = plan.UserID != "" &&
unresolvedProfileForCorp(cfg, plan.CorpID) != nil &&
!plan.UpgradesLegacyProfile
plan.WriteIdentity = plan.UserID != ""
orgCurrentSelector := ""
if cfg != nil {
orgCurrentSelector = cfg.OrgCurrentProfiles[plan.CorpID]
}
// A sole unresolved profile is being completed in place during explicit
// reauthorization. Its organization slot must move with the newly exact
// identity even when an explicit runtime selector keeps it from becoming
// process-global current.
plan.WriteOrganization = plan.UserID == "" ||
plan.UpgradesLegacyProfile ||
(!plan.PreserveUnresolvedOrganization &&
(plan.MakeCurrent ||
exactProfileSelectorForCorp(
cfg,
plan.CorpID,
orgCurrentSelector,
) == plan.ExactSelector))
return plan
}
// IsAccessTokenValid returns true if the access token has not expired.
@@ -219,6 +310,30 @@ func SaveTokenData(configDir string, data *TokenData) error {
})
}
// SaveLoginTokenData is the safe persistence boundary for credentials produced
// by a new login entry point (OAuth/device/PAT authorization or --token). It
// repairs a uniquely recoverable half-migrated legacy login before any global
// mirror can be replaced, marks the incoming credential as a fresh
// authorization for UID-less account isolation, and preflights every slot the
// write plan can touch.
//
// Refresh paths must continue to use SaveTokenData after their refresh-specific
// preflight; treating a refresh as a fresh authorization would incorrectly
// reject the selected unresolved account in a multi-account organization.
func SaveLoginTokenData(configDir string, data *TokenData) error {
if data == nil {
return fmt.Errorf("token data is empty")
}
if err := prepareLoginPersistence(configDir); err != nil {
return fmt.Errorf("local login state cannot be safely updated: %w", err)
}
data.FreshAuthorization = true
if err := preflightTokenWritePersistence(configDir, data); err != nil {
return fmt.Errorf("local login state cannot be safely updated: %w", err)
}
return SaveTokenData(configDir, data)
}
// saveTokenDataLocked performs the keychain + profiles.json + legacy mirror
// writes assuming the auth dual-layer lock is already held. Callers that
// already hold the lock (OAuthProvider refresh path, the legacy secure->keychain
@@ -235,35 +350,56 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
if err != nil {
return err
}
// A login may be the first operation after upgrading. Finish a v1
// registry migration before an upsert can raise profiles.json to v2;
// otherwise untouched organizations would permanently lose their chance
// to receive exact identity token slots. Do not re-run v2 repair here:
// refresh has already rotated the remote credential at this point, and an
// unrelated damaged identity slot must not prevent the new token from
// being committed. Normal load/preflight paths repair v2 before exchange.
if cfg.Version < profilesVersion {
if err := tokenEnsureProfilesMigration(configDir); err != nil {
return err
}
cfg, err = tokenLoadProfiles(configDir)
if err != nil {
return err
}
}
if err := ensureProfilesWritable(cfg); err != nil {
return err
}
runtimeSelector := strings.TrimSpace(RuntimeProfile())
makeCurrent := runtimeSelector == ""
exactSelector := profileSelector(corpID, userID)
mirrorOrg := makeCurrent ||
exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]) == exactSelector
existingIdentity := profileIndexByIdentity(cfg, corpID, userID) >= 0
upgradesLegacyProfile := !existingIdentity && userID != "" && legacyProfileIndexByCorpID(cfg, corpID) >= 0
plan := planTokenPersistenceWrites(cfg, data, RuntimeProfile())
if err := validateTokenPersistenceWritePlan(cfg, data, plan); err != nil {
return err
}
logging.AuthDebug(
"auth.token.persist.plan",
"corp_id", corpID,
"user_id", userID,
"user_name", strings.TrimSpace(data.UserName),
"identity_selector", exactSelector,
"existing_identity", existingIdentity,
"upgrades_legacy_profile", upgradesLegacyProfile,
"identity_selector", plan.ExactSelector,
"existing_identity", plan.ExistingIdentity,
"upgrades_legacy_profile", plan.UpgradesLegacyProfile,
"profiles_before", len(cfg.Profiles),
"runtime_profile", runtimeSelector,
"write_identity_slot", userID != "",
"write_org_mirror", mirrorOrg,
"write_global_mirror", makeCurrent,
"runtime_profile", plan.RuntimeSelector,
"persistence_profile", plan.PersistenceSelector,
"write_identity_slot", plan.WriteIdentity,
"write_org_mirror", plan.WriteOrganization,
"write_global_mirror", plan.WriteGlobal,
"publish_incoming_global", plan.MakeCurrent,
)
snapshot, err := snapshotTokenPersistence(
configDir,
cfg,
plan.CorpID,
plan.UserID,
plan.WriteOrganization,
)
snapshot, err := snapshotTokenPersistence(configDir, cfg, corpID, userID, mirrorOrg)
if err != nil {
return err
}
preserveManualDefault := !makeCurrent &&
preserveManualDefault := !plan.MakeCurrent &&
snapshot.marker.known &&
snapshot.marker.exists &&
snapshot.marker.manual
@@ -273,32 +409,28 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
}
return operationErr
}
if userID != "" {
if plan.WriteIdentity {
if err := tokenSaveKeychainForIdentity(corpID, userID, data); err != nil {
return rollback(err)
}
} else {
for _, profile := range cfg.Profiles {
if strings.TrimSpace(profile.CorpID) == corpID && strings.TrimSpace(profile.UserID) != "" {
return fmt.Errorf("cannot store profile for corpId %q without userId because account identities already exist", corpID)
}
}
}
if err := tokenUpsertProfile(configDir, data, makeCurrent); err != nil {
if err := tokenUpsertProfile(configDir, data, plan.MakeCurrent); err != nil {
return rollback(err)
}
if mirrorOrg {
if plan.WriteOrganization {
if err := tokenSaveKeychainForCorpID(corpID, data); err != nil {
return rollback(err)
}
}
if makeCurrent {
if err := tokenSaveKeychain(data); err != nil {
return rollback(err)
}
} else if !preserveManualDefault {
if err := tokenSyncLegacyMirror(configDir); err != nil {
return rollback(err)
if plan.WriteGlobal {
if plan.MakeCurrent {
if err := tokenSaveKeychain(data); err != nil {
return rollback(err)
}
} else if !preserveManualDefault {
if err := tokenSyncLegacyMirror(configDir); err != nil {
return rollback(err)
}
}
}
if preserveManualDefault {
@@ -313,10 +445,11 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
"corp_id", corpID,
"user_id", userID,
"user_name", strings.TrimSpace(data.UserName),
"identity_selector", exactSelector,
"write_identity_slot", userID != "",
"write_org_mirror", mirrorOrg,
"write_global_mirror", makeCurrent,
"identity_selector", plan.ExactSelector,
"write_identity_slot", plan.WriteIdentity,
"write_org_mirror", plan.WriteOrganization,
"write_global_mirror", plan.WriteGlobal && !preserveManualDefault,
"publish_incoming_global", plan.MakeCurrent,
)
return nil
}
@@ -428,7 +561,7 @@ func loadTokenDataForProfileLocked(configDir, profile string) (*TokenData, error
// as a different organization (the legacy mirror may have drifted).
if legacy, lerr := tokenLoadKeychain(); lerr == nil && legacy != nil &&
strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) &&
(strings.TrimSpace(selected.UserID) == "" || strings.TrimSpace(legacy.UserID) == strings.TrimSpace(selected.UserID)) {
strings.TrimSpace(legacy.UserID) == strings.TrimSpace(selected.UserID) {
return legacy, nil
} else if lerr != nil && !errors.Is(lerr, ErrTokenDataNotFound) {
return nil, lerr
@@ -458,35 +591,129 @@ func loadTokenDataForProfileLocked(configDir, profile string) (*TokenData, error
}
func tokenLoadProfileIdentity(profile Profile) (*TokenData, error) {
corpID := strings.TrimSpace(profile.CorpID)
userID := strings.TrimSpace(profile.UserID)
if strings.TrimSpace(profile.UserID) == "" {
return tokenLoadKeychainForCorpID(profile.CorpID)
data, err := tokenLoadKeychainForCorpID(corpID)
if err != nil {
return nil, err
}
if data == nil {
return nil, ErrTokenDataNotFound
}
if strings.TrimSpace(data.CorpID) != corpID {
return nil, fmt.Errorf(
"organization token mirror for corpId %q contains token for corpId %q; cannot use it for unresolved profile %q",
corpID,
data.CorpID,
profile.Name,
)
}
if strings.TrimSpace(data.UserID) != "" {
return nil, fmt.Errorf(
"organization token mirror for corpId %q belongs to userId %q; cannot use it for unresolved profile %q",
corpID,
data.UserID,
profile.Name,
)
}
return data, nil
}
data, err := tokenLoadKeychainIdentity(profile.CorpID, profile.UserID)
data, err := tokenLoadKeychainIdentity(corpID, userID)
if err == nil {
if data == nil {
return nil, ErrTokenDataNotFound
}
if strings.TrimSpace(data.CorpID) != corpID || strings.TrimSpace(data.UserID) != userID {
return nil, fmt.Errorf(
"identity token slot %q contains token for %q; cannot use it for profile %q",
TokenAccountForIdentity(corpID, userID),
profileSelector(data.CorpID, data.UserID),
ProfileSelector(profile),
)
}
return data, nil
}
if !errors.Is(err, ErrTokenDataNotFound) {
return nil, err
}
orgData, orgErr := tokenLoadKeychainForCorpID(profile.CorpID)
orgData, orgErr := tokenLoadKeychainForCorpID(corpID)
if orgErr != nil {
if errors.Is(orgErr, ErrTokenDataNotFound) {
return nil, err
}
return nil, orgErr
}
if strings.TrimSpace(orgData.UserID) == "" {
return nil, fmt.Errorf("organization token mirror for corpId %q has no userId; cannot use it for profile %q", profile.CorpID, ProfileSelector(profile))
}
if strings.TrimSpace(orgData.UserID) != strings.TrimSpace(profile.UserID) {
if orgData == nil {
return nil, err
}
if saveErr := tokenSaveKeychainForIdentity(profile.CorpID, profile.UserID, orgData); saveErr != nil {
if strings.TrimSpace(orgData.CorpID) != corpID {
return nil, fmt.Errorf(
"organization token mirror for corpId %q contains token for corpId %q; cannot use it for profile %q",
corpID,
orgData.CorpID,
ProfileSelector(profile),
)
}
if strings.TrimSpace(orgData.UserID) == "" {
return nil, fmt.Errorf("organization token mirror for corpId %q has no userId; cannot use it for profile %q", corpID, ProfileSelector(profile))
}
if strings.TrimSpace(orgData.UserID) != userID {
return nil, err
}
if saveErr := tokenSaveKeychainForIdentity(corpID, userID, orgData); saveErr != nil {
return nil, saveErr
}
return orgData, nil
}
// validateTokenPersistenceWritePlan prevents a freshly authorized UID-less
// credential from being attached to an existing unresolved sibling merely
// because both accounts share a corpId. An explicit selector is a storage
// boundary, but it is not proof that an exact account completed OAuth. The only
// safe overwrite is when that selector itself resolves to the existing
// unresolved profile. A blank selector remains allowed for ordinary refreshes
// of the already-selected unresolved token.
func validateTokenPersistenceWritePlan(
cfg *ProfilesConfig,
data *TokenData,
plan tokenPersistenceWritePlan,
) error {
if data == nil || plan.CorpID == "" || plan.UserID != "" {
return nil
}
unresolved := unresolvedProfileForCorp(cfg, plan.CorpID)
if unresolved == nil {
return nil
}
targetSelector := plan.RuntimeSelector
if targetSelector == "" {
targetSelector = strings.TrimSpace(data.LegacyOrgScopedProfile)
}
if targetSelector == "" {
if data.FreshAuthorization && len(profilesForCorpID(cfg, plan.CorpID)) > 1 {
return fmt.Errorf(
"refusing to save a fresh UID-less token over existing unresolved profile %q in multi-account organization %q; retry with that unresolved profile selector or require server-provided userId",
storedProfileSelector(cfg, unresolved),
plan.CorpID,
)
}
return nil
}
selected, _, err := resolveProfileSelection("", cfg, targetSelector)
if err == nil && selected != nil &&
strings.TrimSpace(selected.CorpID) == plan.CorpID &&
strings.TrimSpace(selected.UserID) == "" {
return nil
}
return fmt.Errorf(
"refusing to save UID-less token selected as profile %q over existing unresolved profile %q in organization %q; retry with the unresolved profile selector or require server-provided userId",
targetSelector,
storedProfileSelector(cfg, unresolved),
plan.CorpID,
)
}
// DeleteTokenData removes token data. Edition hooks and the default keychain
// path are both serialized with refresh through the auth dual lock.
func DeleteTokenData(configDir string) error {
@@ -561,12 +788,20 @@ func deleteTokenDataForProfileLocked(configDir, profile string) error {
removeSelector := removed.CorpID
orgCurrent := false
if exact {
removeSelector = ProfileSelector(removed)
orgCurrent = exactProfileSelectorForCorp(
cfg,
removed.CorpID,
cfg.OrgCurrentProfiles[removed.CorpID],
) == ProfileSelector(removed)
if strings.TrimSpace(removed.UserID) == "" {
// A blank profile is exact only when it was selected by its unique
// local name. Converting it back to corpId here would turn a
// one-profile logout into whole-organization deletion.
removeSelector = effectiveSelector
orgCurrent = true
} else {
removeSelector = ProfileSelector(removed)
orgCurrent = exactProfileSelectorForCorp(
cfg,
removed.CorpID,
cfg.OrgCurrentProfiles[removed.CorpID],
) == ProfileSelector(removed)
}
}
if _, err := tokenRemoveProfile(configDir, removeSelector); err != nil {
return err
@@ -592,7 +827,8 @@ func deleteTokenDataForProfileLocked(configDir, profile string) error {
return rollback(loadErr)
}
replacementSelector := updated.OrgCurrentProfiles[removed.CorpID]
if exact && replacementSelector != "" {
preserveUnresolvedOrg := unresolvedProfileForCorp(updated, removed.CorpID) != nil
if exact && replacementSelector != "" && !preserveUnresolvedOrg {
replacement, _, resolveErr := tokenResolveSelection(configDir, updated, replacementSelector)
if resolveErr != nil {
return rollback(resolveErr)
@@ -600,8 +836,10 @@ func deleteTokenDataForProfileLocked(configDir, profile string) error {
if err := tokenSyncOrganizationMirror(*replacement); err != nil {
return rollback(err)
}
} else if err := tokenDeleteKeychainForCorpID(removed.CorpID); err != nil {
return rollback(err)
} else if !preserveUnresolvedOrg {
if err := tokenDeleteKeychainForCorpID(removed.CorpID); err != nil {
return rollback(err)
}
}
}
preserveManualDefault := markerSnapshot.known &&
+88 -28
View File
@@ -21,6 +21,7 @@ import (
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
@@ -210,10 +211,48 @@ func TestExactNonOrgCurrentRefreshIgnoresUnreadableOrgMirror(t *testing.T) {
}
}
func TestExchangeAuthCodePreflightsOrphanProfileCiphertextBeforeHTTP(t *testing.T) {
func TestCrossPlatformCoverageExactRefreshAndSwitchIgnoreUnreadableReservedBlankOrgSlot(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
fixture := seedLegacyBlankAndExactIdentitySlots(t)
if err := os.WriteFile(profileCiphertextPathForTest(fixture.corpID), []byte("corrupt reserved blank slot"), 0o600); err != nil {
t.Fatalf("WriteFile(reserved blank ciphertext) error = %v", err)
}
SetRuntimeProfile("")
if err := preflightTokenRefreshPersistence(fixture.configDir, fixture.beta); err != nil {
t.Fatalf("preflightTokenRefreshPersistence(current exact with reserved blank) error = %v", err)
}
refreshed := *fixture.beta
refreshed.AccessToken = "at_identity_beta_refreshed"
if err := SaveTokenData(fixture.configDir, &refreshed); err != nil {
t.Fatalf("SaveTokenData(current exact with reserved blank) error = %v", err)
}
if raw, err := os.ReadFile(profileCiphertextPathForTest(fixture.corpID)); err != nil || string(raw) != "corrupt reserved blank slot" {
t.Fatalf("reserved blank slot changed during exact refresh: %q, %v", raw, err)
}
if selected, err := SetCurrentProfile(fixture.configDir, profileSelector(fixture.alpha.CorpID, fixture.alpha.UserID)); err != nil || selected.UserID != fixture.alpha.UserID {
t.Fatalf("SetCurrentProfile(exact with reserved blank) = %#v, %v", selected, err)
}
if selected, err := UsePreviousProfile(fixture.configDir); err != nil || selected.UserID != fixture.beta.UserID {
t.Fatalf("UsePreviousProfile(exact with reserved blank) = %#v, %v", selected, err)
}
if raw, err := os.ReadFile(profileCiphertextPathForTest(fixture.corpID)); err != nil || string(raw) != "corrupt reserved blank slot" {
t.Fatalf("reserved blank slot changed during exact switches: %q, %v", raw, err)
}
blankRefresh := *fixture.blank
blankRefresh.LegacyOrgScopedProfile = fixture.blankName
SetRuntimeProfile(fixture.blankName)
if err := preflightTokenRefreshPersistence(fixture.configDir, &blankRefresh); err == nil ||
!strings.Contains(err.Error(), "profile token slot") {
t.Fatalf("blank refresh preflight error = %v, want unreadable reserved slot", err)
}
}
func TestFullTokenPersistenceInventoryDetectsOrphanProfileCiphertext(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
setPreflightTestCredentials(t)
configDir := t.TempDir()
data := testToken("at_orphan", "corp_orphan", "Orphan Org")
@@ -230,21 +269,12 @@ func TestExchangeAuthCodePreflightsOrphanProfileCiphertextBeforeHTTP(t *testing.
t.Fatalf("WriteFile(replacement DEK) error = %v", err)
}
var calls atomic.Int32
provider := NewOAuthProvider(configDir, nil)
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
calls.Add(1)
return nil, errors.New("unexpected HTTP request")
})}
_, err := provider.ExchangeAuthCode(context.Background(), "auth-code", "")
err := preflightTokenPersistence(configDir)
if err == nil || !strings.Contains(err.Error(), "auth token ciphertext inventory") {
t.Fatalf("ExchangeAuthCode() error = %v, want orphan ciphertext preflight error", err)
t.Fatalf("preflightTokenPersistence() error = %v, want orphan ciphertext inventory error", err)
}
if !keychain.IsCiphertextKeyMismatch(err) {
t.Fatalf("ExchangeAuthCode() error = %v, want ciphertext key mismatch in error chain", err)
}
if got := calls.Load(); got != 0 {
t.Fatalf("HTTP calls = %d, want 0", got)
t.Fatalf("preflightTokenPersistence() error = %v, want ciphertext key mismatch in error chain", err)
}
}
@@ -304,7 +334,7 @@ func TestRefreshPreflightIgnoresUnreadableUnrelatedProfile(t *testing.T) {
}
}
func TestOAuthLoginPreflightsTokenPersistence(t *testing.T) {
func TestOAuthLoginUnreadableGlobalFailsClosedBeforeAuthorizationStart(t *testing.T) {
setPreflightTestCredentials(t)
for _, force := range []bool{false, true} {
t.Run("force="+map[bool]string{false: "false", true: "true"}[force], func(t *testing.T) {
@@ -312,33 +342,60 @@ func TestOAuthLoginPreflightsTokenPersistence(t *testing.T) {
configDir := t.TempDir()
seedUnreadableTokenStorage(t, configDir, testToken("at_login", "corp_login", "Login Org"))
ctx, cancel := context.WithCancel(context.Background())
cancel()
listenErr := errors.New("authorization listener reached")
var calls atomic.Int32
oldListen := oauthListen
oauthListen = func(string, string) (net.Listener, error) {
calls.Add(1)
return nil, listenErr
}
t.Cleanup(func() { oauthListen = oldListen })
provider := NewOAuthProvider(configDir, nil)
provider.NoBrowser = true
_, err := provider.Login(ctx, force)
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
t.Fatalf("Login(force=%v) error = %v, want token persistence preflight error", force, err)
_, err := provider.Login(context.Background(), force)
if err == nil || !strings.Contains(err.Error(), "refusing to overwrite") {
t.Fatalf("Login(force=%v) error = %v, want unreadable-global protection", force, err)
}
if errors.Is(err, listenErr) {
t.Fatalf("Login(force=%v) reached authorization listener: %v", force, err)
}
if got := calls.Load(); got != 0 {
t.Fatalf("Login(force=%v) listener calls = %d, want 0", force, got)
}
})
}
}
func TestExchangeAuthCodePreflightsBeforeHTTP(t *testing.T) {
func TestExchangeAuthCodeRejectsUnreadableGlobalBeforeHTTP(t *testing.T) {
cleanupKeychain(t)
setPreflightTestCredentials(t)
configDir := t.TempDir()
seedUnreadableTokenStorage(t, configDir, testToken("at_exchange", "corp_exchange", "Exchange Org"))
existing := testToken("at_exchange", "corp_exchange", "Exchange Org")
seedUnreadableTokenStorage(t, configDir, existing)
var calls atomic.Int32
var saveCalls atomic.Int32
oldSave := oauthSaveToken
oauthSaveToken = func(string, *TokenData) error {
saveCalls.Add(1)
return nil
}
t.Cleanup(func() { oauthSaveToken = oldSave })
provider := NewOAuthProvider(configDir, nil)
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
calls.Add(1)
return nil, errors.New("unexpected HTTP request")
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(
`{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp_exchange"}`,
)),
}, nil
})}
_, err := provider.ExchangeAuthCode(context.Background(), "auth-code", "")
_, err := provider.ExchangeAuthCode(context.Background(), "auth-code", existing.UserID)
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
t.Fatalf("ExchangeAuthCode() error = %v, want token persistence preflight error", err)
t.Fatalf("ExchangeAuthCode() error = %v, want target token persistence error", err)
}
if !keychain.IsCiphertextKeyMismatch(err) {
t.Fatalf("ExchangeAuthCode() error = %v, want ciphertext key mismatch in error chain", err)
@@ -346,9 +403,12 @@ func TestExchangeAuthCodePreflightsBeforeHTTP(t *testing.T) {
if got := calls.Load(); got != 0 {
t.Fatalf("HTTP calls = %d, want 0", got)
}
if got := saveCalls.Load(); got != 0 {
t.Fatalf("SaveTokenData calls = %d, want 0", got)
}
}
func TestDeviceFlowLoginPreflightsBeforeDeviceCodeRequest(t *testing.T) {
func TestDeviceFlowLoginRejectsUnreadableGlobalBeforeDeviceCodeRequest(t *testing.T) {
cleanupKeychain(t)
setPreflightTestCredentials(t)
configDir := t.TempDir()
@@ -366,7 +426,7 @@ func TestDeviceFlowLoginPreflightsBeforeDeviceCodeRequest(t *testing.T) {
provider.SetBaseURL(server.URL)
_, err := provider.Login(context.Background())
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
t.Fatalf("DeviceFlowProvider.Login() error = %v, want token persistence preflight error", err)
t.Fatalf("DeviceFlowProvider.Login() error = %v, want unreadable-global protection", err)
}
if got := calls.Load(); got != 0 {
t.Fatalf("device code requests = %d, want 0", got)
@@ -418,7 +478,7 @@ func TestLockedRefreshRejectsFutureProfilesVersionBeforeHTTP(t *testing.T) {
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
cfg.Version = profilesVersion + 1
cfg.Version = profilesMaxVersion + 1
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
File diff suppressed because it is too large Load Diff
+11
View File
@@ -304,3 +304,14 @@ func splitSchemaPathTokens(raw string) []string {
}
return out
}
// normalizeSchemaQueryCLIPath accepts the historical query spellings while
// keeping authored Registry CLI paths strict and space-separated. Canonical
// identity lookup still runs before this compatibility normalization.
func normalizeSchemaQueryCLIPath(path string) string {
parts := splitSchemaPathTokens(strings.TrimSpace(path))
if len(parts) > 0 && parts[0] == "dws" {
parts = parts[1:]
}
return strings.Join(parts, " ")
}
+135
View File
@@ -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.
// command_meta.go provides the unified metadata consumption API. All runtime
// consumers (help, schema, agent selection, skill generation) call ResolveMeta
// to get a CommandMeta struct — one function, one struct, no need to know which
// of the 6 generation layers a field comes from.
//
// This is the "simple consumption" half of the generation/consumption split:
// - Generation (gen.go + internal/generator/): 6 inputs → catalog snapshot.
// - Consumption (this file): catalog snapshot → ResolveMeta → CommandMeta.
package cli
import (
"sort"
"strings"
"sync"
)
// CommandMeta is the complete runtime metadata view for a single command.
// Consumers read this struct; they never touch the raw catalog maps.
type CommandMeta struct {
Identity CommandIdentity
Safety CommandSafety
Selection CommandSelection
}
// CommandIdentity is the stable identity of a command.
type CommandIdentity struct {
CLIPath string // "dev app delete"
Canonical string // "dev.delete_dev_app"
Aliases []string // ["search", ...]
ProductID string // "devapp"
Title string // one-line description
}
// CommandSelection is the agent-facing selection metadata.
type CommandSelection struct {
AgentSummary string
UseWhen []string
AvoidWhen []string
Examples []string
}
var (
metaByCLIPathOnce sync.Once
metaByCLIPath map[string]CommandMeta
)
// initMetaByCLIPath builds the cli_path → CommandMeta lookup from the embedded
// catalog. Runs once (sync.Once); the catalog is already decoded at package init.
func initMetaByCLIPath() {
metaByCLIPath = buildMetaByCLIPath(embeddedSchemaCatalog())
}
// buildMetaByCLIPath constructs the lookup from a loaded catalog snapshot.
// Split from initMetaByCLIPath so malformed-snapshot guards stay testable.
func buildMetaByCLIPath(loaded loadedSchemaCatalog) map[string]CommandMeta {
lookup := make(map[string]CommandMeta)
if loaded.Snapshot.Tools == nil {
return lookup
}
metas := make([]CommandMeta, 0, len(loaded.Snapshot.Tools))
for _, tool := range loaded.Snapshot.Tools {
cliPath := schemaString(tool["cli_path"])
if cliPath == "" {
continue
}
meta := CommandMeta{
Identity: CommandIdentity{
CLIPath: cliPath,
Canonical: schemaString(tool["canonical_path"]),
Aliases: schemaStringSlice(tool["aliases"]),
ProductID: schemaString(tool["product_id"]),
Title: schemaString(tool["title"]),
},
Safety: CommandSafety{
Effect: schemaString(tool["effect"]),
Risk: schemaString(tool["risk"]),
Confirmation: schemaString(tool["confirmation"]),
Idempotency: schemaString(tool["idempotency"]),
},
Selection: CommandSelection{
AgentSummary: schemaString(tool["agent_summary"]),
UseWhen: schemaStringSlice(tool["use_when"]),
AvoidWhen: schemaStringSlice(tool["avoid_when"]),
Examples: schemaStringSlice(tool["examples"]),
},
}
lookup[cliPath] = meta
metas = append(metas, meta)
}
// Register compat alias paths (e.g. "report list") against the same
// metadata in a second pass, sorted by primary cli_path: primary paths
// always win (registered above, aliases only fill vacancies), and an
// alias-vs-alias collision resolves deterministically to the owner with
// the lexicographically smallest primary path — Snapshot.Tools is a map,
// so relying on iteration order would make the winner vary per process.
sort.Slice(metas, func(i, j int) bool {
return metas[i].Identity.CLIPath < metas[j].Identity.CLIPath
})
for _, meta := range metas {
for _, alias := range meta.Identity.Aliases {
alias = strings.TrimSpace(alias)
if alias == "" || alias == meta.Identity.CLIPath {
continue
}
if _, exists := lookup[alias]; !exists {
lookup[alias] = meta
}
}
}
return lookup
}
// ResolveMeta returns the complete metadata for a command identified by its CLI
// path (e.g. "dev app delete") or one of its compat aliases (e.g. "report list"
// for "report inbox list"). Returns ok=false for commands not in the embedded
// catalog (utility commands, hidden commands, shortcuts).
func ResolveMeta(cliPath string) (CommandMeta, bool) {
metaByCLIPathOnce.Do(initMetaByCLIPath)
m, ok := metaByCLIPath[strings.TrimSpace(cliPath)]
return m, ok
}

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