Compare commits

...
Author SHA1 Message Date
克谨 758b0f875e ci: bound exhaustive coverage test runtimes 2026-08-24 15:28:00 +08:00
github-actions[bot] 19be571574 Merge pull request #1107 from DingTalk-Real-AI/codex/devdoc-hrbrain-pat-shortcuts
feat(shortcut): harden Devdoc HRbrain and PAT surfaces
2026-08-24 14:24:48 +08:00
Dennis b5a287ae71 feat(shortcut): harden devdoc hrbrain and pat surfaces 2026-08-24 13:10:38 +08:00
github-actions[bot] da6f867dfa Merge pull request #1085 from typefield/feat/drive-permission-pagination
feat(drive,doc,wiki): permission/member list pagination and multi-type members
2026-08-24 12:17:02 +08:00
zengyouling.zyl 82dc2b5e5e Merge remote-tracking branch 'upstream/main' into feat/drive-permission-pagination 2026-08-24 11:57:48 +08:00
zengyouling.zyl 9265fd4cb8 fix(skill): point wiki member pagination at native wiki member list 2026-08-24 11:57:36 +08:00
github-actions[bot] e324ef9d4a Merge pull request #1069 from WHUTzju/feat/add-aitable-datasource-tools
Feat/add aitable datasource tools
2026-08-24 11:33:54 +08:00
zengyouling.zyl fb1847d62e Merge remote-tracking branch 'upstream/main' into feat/drive-permission-pagination 2026-08-24 11:15:26 +08:00
zengyouling.zyl 331681e82b ci: retrigger auto-cr to pick up screenshot evidence 2026-08-24 11:05:14 +08:00
陌渊 1633888290 Merge upstream/main: resolve shortcut count conflict + fix field-ids doc
- schemaPublishedShortcutCount: 461→468 after merging aisearch/contact/live
  shortcuts from upstream/main
- Fix P2: datasource-update --field-ids doc says "不传时同步全部字段" but
  actual behavior keeps existing field config; fixed in usage guide and
  reference
2026-08-24 10:54:58 +08:00
github-actions[bot] 206f33ae1c Merge pull request #1083 from DingTalk-Real-AI/codex/shortcut-aisearch-contact-live
feat(shortcut): harden AiSearch Contact and Live task surfaces
2026-08-24 10:44:39 +08:00
陌渊 9259477372 [WP-46-001] fix: update shortcut count constants to match merged sheet/whiteboard shortcuts
publicShortcutCount 422→424, schemaPublishedShortcutCount 460→462,
publiclyDeliveredShortcutCount 422→424
2026-08-24 10:31:32 +08:00
Dennis 137151b38c fix(shortcut): align reviewed live response shapes 2026-08-24 09:57:54 +08:00
陌渊 081220f15e Merge branch 'main' into feat/add-aitable-datasource-tools 2026-08-24 09:50:50 +08:00
Dennis b8216380de fix(aisearch): declare stable result identity 2026-08-23 20:35:11 +08:00
Dennis 83bc213b7f fix(aisearch): reject non-person search sources 2026-08-23 19:40:27 +08:00
Dennis 804b9a3142 fix(contact): normalize exact mobile lookup input 2026-08-23 19:40:26 +08:00
Dennis cfdb0d0556 fix(contact): preserve legacy role placeholders 2026-08-23 19:40:24 +08:00
Dennis d8686122ab fix(schema): reconcile shortcut counts after rebase 2026-08-23 19:40:21 +08:00
Dennis 222a0230ae fix(contact): preserve strict list roles compatibility 2026-08-23 19:40:18 +08:00
Dennis f7befc7943 fix(contact): preserve roster CLI compatibility 2026-08-23 19:40:15 +08:00
Dennis 1dc924af1b fix(contact): align mobile catalog semantics 2026-08-23 19:40:12 +08:00
Dennis 0e8d6e00cf fix(contact): avoid unnecessary mobile detail lookup 2026-08-23 19:40:09 +08:00
Dennis 0028ed1570 fix(contact): restore exact mobile lookup 2026-08-23 19:40:06 +08:00
Dennis 8b5d9a59b0 fix(contact): preserve published schema interface 2026-08-23 19:40:03 +08:00
Dennis 01d663f597 fix(contact): verify exact mobile ownership 2026-08-23 19:40:01 +08:00
Dennis 2bf314c625 fix(contact): preserve list-roles CLI visibility 2026-08-23 19:39:59 +08:00
Dennis 8c98d1abe4 fix(shortcut): harden AiSearch Contact and Live delivery 2026-08-23 19:39:56 +08:00
Dennis 5e4a65513b fix(aisearch): fail closed on unprovable zero results 2026-08-23 19:39:54 +08:00
Dennis 4ae0e0ffc3 fix(contact): close exhaustive shortcut release gate 2026-08-23 19:39:52 +08:00
Dennis 1c9a977d08 fix(shortcut): close residual search and contact gaps 2026-08-23 19:39:50 +08:00
Dennis 78fabec4bb feat(shortcut): fail close Live list task 2026-08-23 19:39:47 +08:00
Dennis 6d6404993a feat(shortcut): harden Contact task surface 2026-08-23 19:39:45 +08:00
Dennis 008d50bb3d feat(shortcut): harden AiSearch task surface 2026-08-23 19:39:40 +08:00
zengyouling.zyl f871689960 ci: retrigger checks after flaky race shard and transient status upload 2026-08-23 19:34:04 +08:00
zengyouling.zyl b15a21de93 Merge remote-tracking branch 'upstream/main' into feat/drive-permission-pagination 2026-08-23 19:00:08 +08:00
github-actions[bot] 58e8e35948 Merge pull request #1098 from typefield/fix/schema-compat-confirmation-exceptions
fix(ci): review batch remove confirmation hardening
2026-08-23 18:52:41 +08:00
zengyouling.zyl a8b0d8895b fix(ci): review batch remove confirmation hardening
Rebuild the exact-entry reviewedCompatibilityExceptions carve-out in the
base-owned schema-compat checker for the three destructive batch-remove
tools whose confirmation PR #1085 tightens from not_required to
user_required (doc/doc.remove_permission, drive/drive.permission_remove,
wiki/wiki.remove_member). Because the compatibility gate builds its
checker from the PR merge-base, this carve-out has to land on main before
PR #1085 can pass; the entry set is exact (tool + field + old -> new), so
any other confirmation drift, including weakening a reviewed tool back to
not_required, still fails.
2026-08-23 18:33:32 +08:00
zengyouling.zyl 546c2d2eb2 fix(helpers): require user confirmation for batch permission/member remove
Address the P1 review finding on PR #1085: --members lets one call remove
up to 30 USER/DEPT/CONVERSATION/TAG members, where departments, chats,
and role groups can indirectly affect many more users, yet the remove
branches called the MCP tool right after argument parsing with Safety
confirmation=not_required.

- drive permission remove, doc permission remove, and wiki member remove
  now declare confirmation=user_required. DeclareLeafMetadata installs
  the ConfirmSafety gate automatically (deferred to the first
  deps.Caller.CallTool so flag validation still fails first), so an
  unconfirmed invocation exits with the typed confirmation_required
  error and performs zero MCP calls; --yes, an interactive yes, or
  --dry-run previews remain the supported paths.
- Pass framework confirmation errors through WrapErrorWithOperation
  verbatim (new apperrors.IsConfirmationRequired). Text classification
  misrouted them: command paths containing "permission" (drive/doc
  permission remove) were re-reported as AUTH_PERMISSION_DENIED while
  other paths (wiki member remove) lost their reason and degraded to
  UNCLASSIFIED.
- Tests: TestPermissionMemberRemoveRequiresConfirmationBeforeToolCall
  covers all three entry points for both --members and legacy --users —
  unconfirmed rejects with zero MCP calls, --yes dispatches exactly one
  call with the complete precise arguments, --dry-run previews without
  calls. Existing remove tests inject root --yes for the assembly
  assertions; blank --users still fails validation before confirmation.
2026-08-23 17:55:33 +08:00
zengyouling.zyl 5bd0ea7c53 fix(helpers): drop NO_PERMISSION from document permission codes
Address the P2 review finding on PR #1085: NO_PERMISSION is a generic
code name also returned by non-document tools — attendance
get-self-setting (bossAttendStatNotify) and event-subscription attempts
have both been observed returning it — so keying drive permission
apply-* guidance on it would mislead those products, defeating the goal
of the P1 scoping fix. Only the drive-specific forbidden.* domain codes
(forbidden.no.auth / forbidden.accessDenied) and the role-threshold
message wording remain document signals; a bare NO_PERMISSION still
classifies as AUTH_PERMISSION_DENIED but now keeps the product-neutral
suggestion, and NO_PERMISSION combined with document wording still gets
apply guidance.

Add regression tests for the non-document NO_PERMISSION case and update
the changelog fragment; changed-code coverage stays at 100%.
2026-08-23 17:01:49 +08:00
zengyouling.zyl 4833b39071 fix(helpers): scope permission-apply guidance and null->{} rendering to confirmed tools
Address the two P1 review findings on PR #1085:

- Permission suggestions: the drive permission apply-* guidance is now
  limited to document/wiki-specific errors (node access codes
  NO_PERMISSION / forbidden.no.auth / forbidden.accessDenied and the
  role-threshold wording). Permission failures from other products keep
  their product-specific suggestion (e.g. the mail mailbox hint) or fall
  back to a product-neutral hint instead of being told to run document
  permission commands that cannot fix their problem.

- Null rendering: the null->{} adaptation is limited to the four tools
  with a confirmed empty-response-means-success contract
  (update_permission / remove_permission / update_member /
  remove_member). Every other tool keeps its raw null output so the
  shared machine-output contract stays unchanged.

Update tests and the changelog fragment accordingly; changed-code
coverage stays at 100%.
2026-08-23 16:16:34 +08:00
zengyouling.zyl 8a4e49dbf2 test(helpers): cover permission update/remove members and blank --users branches to #1085 2026-08-23 14:34:17 +08:00
zengyouling.zyl 7c924c54ca fix(drive,doc,wiki): register limit mapping exclusion instead of property redirect to #1085
The server rejects the legacy maxResults path; the CLI now validates
--limit (1-50) and sends it as pageSize at runtime. Schema-compat
rejects a non-empty property redirect (maxResults -> pageSize), so
declare --limit as a CLI pagination input via the reviewed mapping
exclusion ledger (property omitted, provenance
reviewed_mapping_exclusion) on doc.list_permission,
drive.list_permission, and wiki.list_member.
2026-08-23 03:04:11 +08:00
zengyouling.zyl 59d0b6dd75 Merge remote-tracking branch 'upstream/main' into feat/drive-permission-pagination 2026-08-23 02:52:58 +08:00
zengyouling.zyl 74f7bbc980 docs(changes): correct release fragment PR reference to #1085 2026-08-23 02:38:43 +08:00
zengyouling.zyl 2b7d5a2c5f fix(drive,doc,wiki): permission notify default, error guidance, pagination contract to #1065
- --notify now defaults to false and is omitted from the server request
  unless passed explicitly (help updated accordingly)
- forbidden.accessDenied / permission-denied bodies classify as
  AUTH_PERMISSION_DENIED with apply-permission guidance
- user/member validation failures intercepted before RESOURCE_NOT_FOUND
  with --members corpId suggestion
- business error display appends backend code/logId for traceability;
  literal null tool responses render as {}
- drive/doc permission list + wiki member list declare cursor pagination
  (next-token) in Contract; cobra.NoArgs hardening on permission leaves
- cross-platform coverage tests and release fragments updated
2026-08-23 02:25:57 +08:00
github-actions[bot] fcfead71cb Merge pull request #1082 from DingTalk-Real-AI/codex/shortcut-sheet-whiteboard-markdown
feat(shortcuts): harden Sheet Whiteboard and Markdown routes
2026-08-23 01:14:11 +08:00
Dennis 0beb1c6b0c fix(whiteboard): compare readback numbers exactly 2026-08-23 00:54:46 +08:00
Dennis 3b38d4c8da docs(whiteboard): fix shortcut file source syntax 2026-08-23 00:30:58 +08:00
Dennis d68e340a5b fix(whiteboard): preserve interactive confirmation in examples 2026-08-23 00:30:56 +08:00
Dennis 98799effba fix(shortcuts): close sheet and whiteboard review gaps 2026-08-23 00:30:54 +08:00
Dennis 9239f9070a test(ci): share shortcut schema boundary fixture 2026-08-23 00:30:52 +08:00
Dennis 202c5ce697 feat(shortcuts): harden Sheet Whiteboard and Markdown routes 2026-08-23 00:30:49 +08:00
github-actions[bot] 8ab2ac5e7c Merge pull request #994 from FloralTide/codex/fix-event-shutdown-lifecycle
fix(event): clean up shutdown lifecycle
2026-08-21 19:24:44 +08:00
炳昱 b85a342e9f fix(npm): preserve interactive terminal ownership 2026-08-21 19:09:59 +08:00
炳昱 ad72cf4b3d fix(npm): signal the vendor process group 2026-08-21 18:15:19 +08:00
炳昱 89154b3952 fix(npm): avoid duplicate terminal signals 2026-08-21 17:39:48 +08:00
炳昱 b01febf52e Merge remote-tracking branch 'official-upstream/main' into codex/fix-event-shutdown-lifecycle 2026-08-21 17:23:25 +08:00
github-actions[bot] 74b7690cbb Merge pull request #1078 from liyuan333/feat/doc-read-public-and-history-version
feat(doc): read password-protected public docs and historical versions
2026-08-21 17:19:39 +08:00
liyuan333 8312c4f30e Merge branch 'main' into feat/doc-read-public-and-history-version 2026-08-21 16:50:25 +08:00
赤川 35c6fd95e1 Merge pull request #1092 from DingTalk-Real-AI/codex/add-secondary-dingtalk-webhook
ci: notify a secondary DingTalk webhook
2026-08-21 16:24:19 +08:00
chichuan 564ff8563f ci: notify a secondary DingTalk webhook 2026-08-21 16:22:46 +08:00
陌渊 c7510cd1a1 fix(datasource): trim whitespace from batch IDs and fix result/processCode docs
- Add trimNonEmpty for --table-ids in +datasource-sync and --task-ids in
  +datasource-sync-status, matching the existing field-ids pattern
- Add 4 test cases: whitespace-only rejection and trim-through for both
- Fix usage guide: typical workflow and notes no longer equate result
  with processCode; correctly describe result as JSON to parse for
  approvals[].processCode/name/iconUrl/url
2026-08-21 16:19:43 +08:00
john 1c3477c087 Merge branch 'main' into feat/drive-permission-pagination 2026-08-21 16:18:15 +08:00
陌渊 93d450a9bf fix(datasource): read --field-ids as string slice, not string
--field-ids is declared as FlagStringSlice, but DatasourceCreate and
DatasourceUpdate previously called rt.Str to check whether the flag
was empty. RuntimeContext.Str delegates to cobra's GetString, which
returns an empty string on slice-typed flags, so the empty-value
guard rejected every explicit --field-ids input and the downstream
MCP tool never received fieldIds.

Switch to rt.StrSlice, sanitize through a new trimNonEmpty helper
(drop whitespace-only / empty entries) and pass the cleaned slice
to MCP. Add success-passthrough tests for both create and update,
plus a whitespace-only rejection case, and enhance the mock caller
to record MCP arguments so fieldIds can be asserted.
2026-08-21 16:10:23 +08:00
陌渊 cf39768095 fix(datasource): align field-ids semantics and test naming for coverage gate
- Update --field-ids description in create/update shortcuts and the
  helper-layer datasource update to clarify that omitting the flag
  keeps existing config (create defaults to all fields), matching the
  actual update overwrite semantics.
- Rename datasource shortcut coverage tests to the
  TestCrossPlatformCoverage* prefix so they are picked up by the
  macOS platform coverage gate.
2026-08-21 16:10:20 +08:00
陌渊 c096258b0f fix(datasource): reject empty field-ids and auto-sync-setting in shortcut layer
Align shortcut layer validation with helper layer to prevent empty slices
from being sent to MCP, which could clear sync field selection due to
datasource update's overwrite semantics.

- Add empty string checks for --field-ids in both create and update shortcuts
- Add empty string checks for --auto-sync-setting in both create and update shortcuts
- Add regression tests verifying MCP is not called when empty values are rejected
- Both public entry points now have consistent validation behavior

Fixes P1 auto-CR issue for empty flag bypass vulnerability.
2026-08-21 16:10:18 +08:00
陌渊 5ba8ac6775 test(aitable): cover datasource shortcut and helper error paths for 100% changed-code coverage 2026-08-21 16:10:15 +08:00
陌渊 66fee5ef5f fix(aitable): update shortcut counts after rebase onto upstream main 2026-08-21 16:10:12 +08:00
陌渊 d9b9c5c7da fix(aitable): reject empty field-ids/auto-sync-setting and non-object JSON 2026-08-21 16:10:09 +08:00
陌渊 684411e54e fix(aitable): require task-ids for datasource sync-status and align docs
Make +datasource-sync-status consistent across shortcut and native
commands: --task-ids is now required, descriptions focus on querying
by taskId, and optional/IDLE semantics are removed. Update tests,
usage guide, reference doc, and SKILL description accordingly.
2026-08-21 16:10:05 +08:00
陌渊 de5ba029d4 fix(aitable): add field-ids/auto-sync-setting to native datasource create/update
Native datasource create/update now expose --field-ids and
--auto-sync-setting, matching the shortcut-layer capabilities:
- flags registered on both commands
- Contract Parameters updated
- values mapped to MCP tool args
- JSON validation for --auto-sync-setting
- no-change update guard now counts the new flags

Also fixes the missing required name in the usage-guide update example.
2026-08-21 16:10:02 +08:00
陌渊 15f139e32d fix(aitable): reject no-change datasource update and fix doc example
+datasource-update now requires at least one mutable option
(--source-config, --auto, --field-ids, or --auto-sync-setting)
before calling update_datasource_config, preventing accidental
sync triggers. The native datasource update command enforces the
same guard for its supported flags. Also adds the required name
field to the +datasource-get-fields doc example.
2026-08-21 16:09:58 +08:00
陌渊 768c1ce494 fix(aitable): only send --auto on datasource-update when explicitly set
Omitting --auto on +datasource-update previously sent auto=false to
MCP, silently disabling auto-sync for existing datasources. Now auto
is only included in tool args when the flag is explicitly provided,
so --auto=true and --auto=false work while omission preserves the
existing setting. Updated flag descriptions and added tests.
2026-08-21 16:09:55 +08:00
陌渊 966fd60e2f fix(aitable): always send auto=false for datasource create/update
MCP requires the auto field in create_datasource / update_datasource_config
requests. Previously CLI only sent it when --auto was explicitly changed,
causing failures when users omitted the flag. Now both shortcut and helper
layers always include auto=false by default.

Also update flag descriptions and docs to clarify that the field is always
sent downstream, and add test assertions for the default-false behavior.
2026-08-21 16:09:51 +08:00
陌渊 7825c3c7e0 docs(aitable): fix datasource doc inconsistencies for auto CR P2
- docs/datasource-usage-guide.md: clarify that list-sources result is a
  JSON string containing approvals[]; add missing --auto-sync-setting
  parameter table rows and a dedicated autoSyncSetting format section
  using the correct scheduled/daily/weekly/monthly enums.
- skills/references/aitable/aitable-datasource.md: fix autoSyncSetting
  enums (schedule/day/week/month -> scheduled/daily/weekly/monthly) and
  update the create example accordingly.
2026-08-21 16:09:49 +08:00
陌渊 10d44615d1 fix(aitable): include required name in datasource source-config examples
The OA approval source-config contract requires processCode, name,
iconUrl, and url to be passed through unchanged from +datasource-list-sources.
Published examples for +datasource-create, +datasource-update, and
+datasource-get-fields were missing `name`, and the usage guide marked it
as optional. Fix all examples in the shortcut layer, helper layer, and
docs; update flag descriptions to mention name; and add a contract test
that validates every delivered example's source-config JSON contains the
required members.
2026-08-21 16:09:39 +08:00
陌渊 1e88612e43 test(aitable): add datasource helper tests for 100% changed-code coverage
21 tests covering all 7 datasource leaf commands' error paths (missing
required flags, count validation) and happy paths (source-config as raw
string, --auto flag, boundary cases for table-ids/task-ids).
2026-08-21 16:09:36 +08:00
陌渊 5934ccac7f fix(aitable): enforce 1-5 count limit on table-ids and task-ids
Both the shortcut (+datasource-sync, +datasource-sync-status) and
helper (datasource sync, datasource sync-status) layers now validate
that table-ids contains 1-5 IDs and task-ids contains at most 5 IDs
before calling MCP, matching the declared contract.
2026-08-21 16:09:30 +08:00
陌渊 d9365f3fff docs: remove unimplemented --conflict-strategy from all datasource docs 2026-08-21 16:09:27 +08:00
陌渊 15d93698bd fix(aitable): use String instead of StringSlice for datasource flags
ValidateRequiredFlags calls GetString which returns empty for
StringSlice flags, causing the examples test to report --table-ids
as missing. Switch to String + parseCSVValues to match the codebase
convention used by record-ids and other comma-separated flags.
2026-08-21 16:09:23 +08:00
陌渊 7e69a3d6fe fix(aitable): add datasource helper leaf commands and fix CI test counts
- Add 7 datasource leaf commands to internal/helpers/aitable.go so
  coverage test can find tool name literals (fixes TestAllShortcutsAssemble)
- Add 7 entries to semantic_catalog_aitable.json and update catalog count
  from 93 to 100 (fixes TestCrossPlatformCoverageAITableSemanticCatalog)
- Update publicShortcutCount/schemaPublishedShortcutCount/publiclyDelivered
  from 422/447/422 to 429/454/429 (fixes TestDeliverySchemaCoversOrExactly)
- Fix Contract.Selection.AgentSummary and UseWhen[0] in datasource.go to
  match Description and Intent exactly as required by schema contract test
2026-08-21 16:09:21 +08:00
陌渊 94b4958038 chore(aitable): regenerate SKILL.md shortcut section via gen_skill_shortcut_sections.py 2026-08-21 16:09:19 +08:00
陌渊 97a99ba04f style: fix gofmt indentation in datasource.go 2026-08-21 16:09:17 +08:00
陌渊 5e1e5cbb84 fix(aitable): remove duplicate datasource-get-fields and datasource-list-sources rows in SKILL.md 2026-08-21 16:09:14 +08:00
陌渊 e04e886c50 chore: add release fragment for aitable datasource shortcuts 2026-08-21 16:09:12 +08:00
陌渊 1acde9b766 feat(aitable): align datasource shortcuts with MCP snapshot [WP-40-006]
- Fix autoSyncSetting enum: scheduled/daily/weekly/monthly; mark
  selectedMonthDays/selectedWeekdays as required for monthly/weekly
- Remove splitParentTableField from --source-config user-settable fields;
  add note that splitParentTableField/enableDataSyncOaDetailList are
  internal downstream fields not to be passed
- Prepend sync-is-fire-and-forget notice to DatasourceSync descriptions
- Remove --conflict-strategy flag (syncConflictStrategy not in MCP schema)
2026-08-21 16:09:09 +08:00
陌渊 852efe56aa feat(aitable): add datasource skill optimization
- Golden Route: add datasource entry (list-sources → create flow)
- 常用 leaf 直达: add datasource-* commands
- 当前最短路径: add list-sources-first rule
- 安全边界: add sync write warning
- 错误最短路径: add errorCode=4014 and sync=false handling
- 按需加载: add datasource reference trigger
- New reference: aitable-datasource.md with full workflow, sourceConfig
  protocol, autoSyncSetting config, command details, error codes
2026-08-21 16:09:05 +08:00
陌渊 6c287bcb4d feat(aitable): align datasource shortcuts with MCP snapshot [WP-40-005]
- Add --auto-sync-setting flag to DatasourceCreate (was only in Execute, not in Flags)
- Expand DatasourceSync description: add 文档链接, errorCode=4014 幂等冲突, 非数据源表参数错误
- Simplify DatasourceGetFields description: remove field property enumeration to match snapshot
2026-08-21 16:09:00 +08:00
陌渊 df24d53886 feat(aitable): align datasource shortcuts with MCP snapshot [WP-40-004]
Sync CLI field descriptions with latest ai-table-mcp-snapshot.json:
- source-config flags: restructure to "两类字段" (4 passthrough + caller-set),
  add splitParentTableField, fix Update flag to optional semantics
- get_datasource_sync_status: update status list (RUNNING/FINISHED/FAILED,
  remove TIMEOUT), change "不传返回最近一次" → "IDLE(下游暂不支持)"
- get_datasource_config: add sync=true guard note, "其他类型暂不支持", sourceConfig hint
- list_datasource_sources: full rewrite explaining result/approvals structure,
  4-field passthrough rule, enableDataSyncOaDetailList internal note
- get_datasource_fields: add "其他数据源类型暂不支持,待后续开放"
2026-08-21 16:08:50 +08:00
陌渊 49cecabb12 [WP-40-003] feat: align 7 datasource shortcuts with latest MCP snapshot
- Add --auto-sync-setting flag (JSON string) to +datasource-create and
  +datasource-update, validated and passed through as raw string.
- Update +datasource-update --source-config desc to reflect full
  replacement semantics ("传入时整体覆盖") and spell out required /
  optional fields with defaults.
- Append "仅支持 OA 审批数据源 (datasourceType=OA)" to
  +datasource-get-config description.
- Simplify +datasource-list-sources / +datasource-get-fields
  descriptions to concise Chinese aligned with snapshot wording.
- Update SKILL.md shortcuts table and add datasource usage guide.
2026-08-21 16:08:45 +08:00
陌渊 09993ad82c [WP-40-002] fix: correct idempotency value from not_idempotent to non_idempotent 2026-08-21 16:08:42 +08:00
陌渊 0efaf6c82f [WP-40-002] feat: update SKILL.md with 5 datasource shortcuts and trigger words 2026-08-21 16:08:39 +08:00
陌渊 26002637f4 [WP-40-001] feat: add 5 datasource shortcuts for aitable
Add 5 data source sync management shortcuts to the aitable service:
- +datasource-create (create_datasource): create sync config + first sync
- +datasource-update (update_datasource_config): update existing sync config
- +datasource-sync (run_datasource_sync): trigger manual sync (max 5 tables)
- +datasource-sync-status (get_datasource_sync_status): query sync task status
- +datasource-get-config (get_datasource_config): get sync config details

Each shortcut declares a full Contract (Identity/Interface/Selection),
Safety, Flags, and Execute that calls rt.CallMCPData on the "aitable"
MCP server. datasource-type is passed through without CLI enum check;
source-config is validated as a JSON object via parseJSONObject.
2026-08-21 16:08:35 +08:00
github-actions[bot] f7229091ae Merge pull request #1053 from anxiangbo/feat/20260817_agoal_search
Feat/20260817 agoal search
2026-08-21 07:50:26 +00:00
liyuan333 77aa813467 Merge branch 'main' into feat/doc-read-public-and-history-version 2026-08-21 15:42:53 +08:00
anxiangbo 1f595571c0 Merge branch 'main' into feat/20260817_agoal_search 2026-08-21 15:27:26 +08:00
github-actions[bot] cd90d1c322 Merge pull request #1075 from Justper/oa_attachment_upload_dws
Oa attachment upload dws
2026-08-21 14:46:26 +08:00
liyuan 49ab53ea0d 评审问题修复 2026-08-21 14:29:05 +08:00
昭逸 78433198fb Merge branch 'oa_attachment_upload_dws' of github.com:Justper/dingtalk-workspace-cli into oa_attachment_upload_dws
to #666
2026-08-21 14:23:27 +08:00
昭逸 4863152a4a Merge remote-tracking branch 'upstream/main' into oa_attachment_upload_dws
to #666
2026-08-21 14:20:33 +08:00
昭逸 2057fec3b0 fix(oa): normalize spaceId/fileSize to match ResultSpec integer declaration to #666
- validateOAAttachmentCommitResult 改为返回归一化后的 result map
- string 型 spaceId 通过 ParseInt 转 int64,json.Number 同理,非法字符串报错
- fileSize 的 json.Number 同样归一化为 int64
- 新增归一化行为测试 + 输出契约测试,覆盖率 100% to #666
2026-08-21 14:19:53 +08:00
anxiangbo 1308d08862 Merge branch 'DingTalk-Real-AI:main' into feat/20260817_agoal_search 2026-08-21 14:00:30 +08:00
github-actions[bot] 8b56e9bc9e Merge pull request #1073 from maoqxxmm/codex/sheet-revision-changeset
feat(sheet): add revision and changeset inspection
2026-08-21 05:56:10 +00:00
毛球 87e141f2de Merge branch 'main' into codex/sheet-revision-changeset 2026-08-21 13:38:56 +08:00
github-actions[bot] 9b521f0392 chore: update beta formula for v1.0.60-beta.1 [skip ci] 2026-08-21 05:17:50 +00:00
YanChangzhi 4dcd528bc1 Merge branch 'main' into oa_attachment_upload_dws 2026-08-21 13:11:34 +08:00
赤川 0bbb3a9d32 Merge pull request #1087 from DingTalk-Real-AI/codex/changelog-v1.0.60-beta.1
chore: prepare v1.0.60-beta.1 changelog
2026-08-21 12:48:22 +08:00
chichuan 0ebd840ba9 chore: prepare v1.0.60-beta.1 changelog 2026-08-21 12:35:38 +08:00
github-actions[bot] 9d356cd664 Merge pull request #1076 from hlzjsong/refresh_org_slot_fix
refresh org slot not only identity
2026-08-21 04:20:48 +00:00
YanChangzhi b00f43ee06 Merge branch 'main' into oa_attachment_upload_dws 2026-08-21 12:14:19 +08:00
赤川 23167ef974 Merge branch 'main' into refresh_org_slot_fix 2026-08-21 11:46:45 +08:00
毛球 8b003aef16 Merge branch 'main' into codex/sheet-revision-changeset 2026-08-21 11:43:45 +08:00
github-actions[bot] 11934eed05 Merge pull request #1081 from DingTalk-Real-AI/codex/fix-report-requiredness-governance
feat(policy): govern optional-to-required flag migrations
2026-08-21 11:40:11 +08:00
玉澜 d552c59d11 feat(drive,doc,wiki): permission/member list pagination and multi-type members
Sync the permission CRUD overhaul from the internal CLI (MR 28965577):

- drive/doc permission list and wiki member list now accept --next-token
  to follow the server cursor (totalCount/hasMore/nextToken); --limit maps
  to pageSize capped at 50 instead of the rejected maxResults=200 path
  (fixes #1065)
- permission add/update/remove and wiki member add/update/remove accept a
  --members JSON array (USER/DEPT/CONVERSATION/TAG grantee types, each with
  its own roleId) with optional --notify; legacy --users/--role stays
- cursor/page-token hidden cross-product aliases now resolve to next-token
- regenerate param_aliases_generated.go; wiki member list override no
  longer blocks cursor
- update mono/multi skill references and add change fragment
2026-08-21 11:30:22 +08:00
YanChangzhi 23e1085a37 Merge branch 'main' into oa_attachment_upload_dws 2026-08-21 11:16:29 +08:00
赤川 a352615e77 Merge branch 'main' into refresh_org_slot_fix 2026-08-21 11:15:06 +08:00
xiatian d210da501a Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-21 11:14:03 +08:00
赤川 6288199a93 Merge branch 'main' into codex/fix-report-requiredness-governance 2026-08-21 11:05:33 +08:00
anxiangbo 6d9781fe15 Merge branch 'main' into feat/20260817_agoal_search 2026-08-21 11:04:19 +08:00
赤川 5b34ed1a7e Merge pull request #1084 from DingTalk-Real-AI/codex/docs-dws-cli-open
docs: 公告 DWS CLI 全面开放
2026-08-21 11:02:36 +08:00
chichuan 3d9a469347 docs: announce DWS CLI availability 2026-08-21 11:01:08 +08:00
xiatian 7640ba7614 fix(sheet): validate changeset audit integrity 2026-08-21 10:59:14 +08:00
anxiangbo c790fe3c3b Merge branch 'main' into feat/20260817_agoal_search 2026-08-21 10:44:53 +08:00
昭逸 4886bdb3f5 Merge remote-tracking branch 'upstream/main' into oa_attachment_upload_dws
to #666
2026-08-21 10:41:06 +08:00
昭逸 4aef07ccd3 fix(oa): validate commit response required fields before reporting success to #666
- 新增 validateOAAttachmentCommitResult 校验 spaceId/fileName/fileSize/fileId 必需字段
- commit 步不再使用通用 callOAAttachmentResultCtx,改为专用校验后才存储成功结果
- 补充 malformed commit 响应回归测试,覆盖率 100%
2026-08-21 10:40:46 +08:00
github-actions[bot] e0c49377d6 Merge pull request #1074 from DingTalk-Real-AI/codex/investigate-calendar-todo-comment-regressions
fix: harden calendar todo and comment shortcuts
2026-08-21 10:30:04 +08:00
昭逸 dc37dd2c34 fix(oa): remove DDAttachment from unsupported table and use testseam.Swap to #666
- 从 oa-form-components.md (mono/multi) 的"API 不支持的控件"表中移除 DDAttachment,避免 Agent 误判为不支持
- computeFileMD5 测试注入改为 testseam.Swap,符合仓库包变量注入约定
2026-08-21 09:56:48 +08:00
hlzjsong 02aad9020a Merge branch 'main' into refresh_org_slot_fix 2026-08-21 09:26:39 +08:00
昭逸 d59d1091dc Merge remote-tracking branch 'upstream/main' into oa_attachment_upload_dws
to #666
2026-08-21 08:51:13 +08:00
昭逸 60d6bfeec4 Merge branch 'oa_attachment_upload_dws' of github.com:Justper/dingtalk-workspace-cli into oa_attachment_upload_dws
to #666
2026-08-21 08:50:28 +08:00
昭逸 bf89acb3d2 fix ci fail to #666 2026-08-21 08:50:13 +08:00
Dennis 35d6f47bd6 Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-21 08:17:52 +08:00
xiatian d24b71614a Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-21 01:23:17 +08:00
github-actions[bot] 765b961f4d Merge pull request #1071 from DingTalk-Real-AI/codex/fix-stable-active-fragments
fix(release): consume post-beta fragments in stable seals
2026-08-21 01:06:18 +08:00
xiatian 9ab4bd10a5 Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-21 01:05:47 +08:00
chichuan 14c5bed4fc ci: split app-c race partition for runner headroom 2026-08-21 00:50:46 +08:00
赤川 c1bd6dcf64 Merge branch 'main' into codex/fix-stable-active-fragments 2026-08-21 00:06:27 +08:00
Dennis 1c8b83ec2f Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-20 23:58:13 +08:00
Dennis bb6f470df5 test: address shortcut regression review 2026-08-20 23:56:08 +08:00
赤川 01af71a5ae Merge branch 'main' into oa_attachment_upload_dws 2026-08-20 23:47:06 +08:00
github-actions[bot] d4ff8a5f4f Merge pull request #1070 from DingTalk-Real-AI/codex/oa-ding-report-shortcuts
feat(shortcut): harden OA DING and Report workflows
2026-08-20 23:44:23 +08:00
赤川 47e3c2b3c5 Merge branch 'main' into refresh_org_slot_fix 2026-08-20 23:19:33 +08:00
xiatian e8ecff586a fix(sheet): classify malformed revision responses 2026-08-20 23:06:56 +08:00
Dennis 11a9ad5d49 fix(shortcut): align OA execution availability 2026-08-20 23:03:35 +08:00
Dennis 23940e4752 revert: keep coverage shard output compact 2026-08-20 22:24:17 +08:00
Dennis 8cb0f64477 fix(shortcut): reject backward OA cursors 2026-08-20 22:15:27 +08:00
Dennis bbaf033618 ci: stream app coverage progress 2026-08-20 22:14:35 +08:00
xiatian 57cca7ef71 fix(sheet): validate revision result contracts 2026-08-20 22:02:26 +08:00
Dennis 2d38beb7be fix(shortcuts): separate compatibility visibility from availability 2026-08-20 21:51:21 +08:00
昭逸 4372a8c5ba Merge remote-tracking branch 'upstream/main' into oa_attachment_upload_dws
to #666
2026-08-20 21:44:16 +08:00
昭逸 422dc0fde3 fix ci fail to #666 2026-08-20 21:44:05 +08:00
muling.cs 3d59411a1a refresh slot repair org 2026-08-20 21:24:06 +08:00
chichuan fe66ac18a4 feat(policy): govern flag requiredness changes 2026-08-20 21:19:55 +08:00
Dennis bda408d966 test(ding): cover compatibility reminder mappings 2026-08-20 21:10:45 +08:00
Dennis 33631502c9 fix(shortcuts): align unavailable writes and query validation 2026-08-20 21:02:52 +08:00
毛球 f2a608d146 Merge branch 'main' into codex/sheet-revision-changeset 2026-08-20 20:44:17 +08:00
Dennis 378b9f67a2 Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-20 20:41:56 +08:00
xiatian 43ba466783 fix(sheet): require fresh confirmation for version revert 2026-08-20 20:28:35 +08:00
Dennis 5c9f738012 fix(shortcuts): preserve published schema bindings 2026-08-20 20:20:41 +08:00
Dennis 56c5fb35b8 chore(policy): retire completed flag migrations 2026-08-20 20:20:27 +08:00
Dennis b19c52f61b fix(oa): make approval keyword normalization explicit 2026-08-20 20:20:25 +08:00
Dennis c0641dbf64 fix(shortcut): preserve OA and DING CLI compatibility 2026-08-20 20:20:23 +08:00
Dennis 3b5cb3b0cb test(shortcut): close OA DING Report coverage gaps 2026-08-20 20:20:21 +08:00
Dennis fd2ed2174f chore(release): add OA DING Report fragment 2026-08-20 20:20:19 +08:00
Dennis 5bbaa304e3 docs(shortcut): refresh OA DING Report live evidence 2026-08-20 20:20:17 +08:00
Dennis db938778af chore(shortcut): sync OA DING Report with current main 2026-08-20 20:20:15 +08:00
Dennis 1155b9b5c0 test(shortcut): align OA reviewed input fixtures 2026-08-20 20:20:12 +08:00
Dennis 8fa93ef030 fix(shortcut): retire OA discovery aliases after downgrade 2026-08-20 20:20:10 +08:00
Dennis f4ad1a15f5 docs(shortcut): record Report double-layer release proof 2026-08-20 20:20:08 +08:00
Dennis d2cbd928e2 docs(shortcut): record DING double-layer release proof 2026-08-20 20:20:06 +08:00
Dennis 2346dfba4e fix(shortcut): require OA zero-page pagination evidence 2026-08-20 20:20:03 +08:00
Dennis f6ad2fa01a fix(shortcut): publish Report range constraints 2026-08-20 20:19:44 +08:00
Dennis 7bc56f3dea feat(shortcut): unlock Report outbox workflows 2026-08-20 20:19:41 +08:00
Dennis 03001eb4e0 fix(shortcut): harden DING write routing evidence 2026-08-20 20:19:39 +08:00
Dennis 91974ce981 docs(shortcut): close OA residual audit gaps 2026-08-20 20:19:37 +08:00
Dennis c6417e3527 feat(shortcut): harden Report reads and availability 2026-08-20 20:19:35 +08:00
Dennis 161687ae4c fix(shortcut): deliver OA validation evidence 2026-08-20 20:19:32 +08:00
Dennis 4da5a07d1d feat(shortcut): harden DING reads and availability 2026-08-20 20:19:30 +08:00
Dennis 2cb83d388b fix(shortcut): publish OA validation constraints 2026-08-20 20:19:25 +08:00
Dennis ba9c0f624e feat(shortcut): harden OA workflows and availability 2026-08-20 20:19:19 +08:00
Dennis ff65f80c98 refactor(oa): add strict shortcut response helpers 2026-08-20 20:19:09 +08:00
github-actions[bot] 42240f5e9e Merge pull request #1079 from DingTalk-Real-AI/codex/fix-stable-migration-receipts
fix(ci): keep completed migration receipts inert
2026-08-20 20:18:04 +08:00
Dennis e6b06b561d Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-20 19:50:43 +08:00
chichuan 11cbc30a10 fix(ci): keep completed migration receipts inert 2026-08-20 19:16:07 +08:00
xiatian 60a474f30c fix(sheet): fail closed on invalid revision results 2026-08-20 19:15:48 +08:00
xiatian 6e8fec5684 chore(policy): retire consumed flag migrations 2026-08-20 17:48:59 +08:00
liyuan 470aa42d7b chore: keep release note in .changes fragment, restore CHANGELOG.md 2026-08-20 17:35:03 +08:00
liyuan a74d96bb96 feat(doc): read password-protected public docs and historical versions 2026-08-20 17:32:11 +08:00
liyuan 9798a60728 feat(doc): read password-protected public docs and historical versions 2026-08-20 17:13:46 +08:00
毛球 e028d443d5 Merge branch 'main' into codex/sheet-revision-changeset 2026-08-20 17:11:29 +08:00
chichuan 74859b966b fix(release): consume active fragments in stable seals 2026-08-20 17:07:53 +08:00
xiatian 76a5559ca2 fix(sheet): align revision dry-run contract 2026-08-20 16:59:18 +08:00
昭逸 c4a1018213 删除无关文件 to #666 2026-08-20 16:55:18 +08:00
github-actions[bot] 62d72ad84c chore: update formula for v1.0.59 [skip ci] 2026-08-20 08:45:55 +00:00
Dennis e6fe475aea chore: retire consumed chat flag migration 2026-08-20 16:44:07 +08:00
muling.cs e95ac52ff4 refresh org slot not only identity 2026-08-20 16:41:35 +08:00
Dennis 61f8140f91 test: close shortcut regression coverage gaps 2026-08-20 16:38:05 +08:00
昭逸 09c0cd7849 merge uptream to #666 2026-08-20 16:26:32 +08:00
xiatian 5a368a9ab8 Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-20 16:11:37 +08:00
Dennis fbcd8887ee fix: harden calendar todo and comment shortcuts 2026-08-20 16:09:18 +08:00
赤川 c0838e7e41 Merge pull request #1072 from DingTalk-Real-AI/codex/changelog-v1.0.59-stable
chore: prepare v1.0.59 changelog
2026-08-20 16:06:54 +08:00
chichuan 9c6ab99bf1 chore: prepare v1.0.59 changelog 2026-08-20 16:01:09 +08:00
github-actions[bot] 87ab311764 chore: update beta formula for v1.0.59-beta.5 [skip ci] 2026-08-20 07:55:40 +00:00
昭逸 7f4318a10d fix审批skill中附件描述 to #666 2026-08-20 15:39:14 +08:00
xiatian 5232f632c8 Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-20 15:35:19 +08:00
xiatian 615a775fdf feat(sheet): add revision changeset inspection 2026-08-20 15:34:45 +08:00
anxiangbo b10da77e09 Merge branch 'main' into feat/20260817_agoal_search 2026-08-20 15:22:19 +08:00
昭逸 cefc5c005c 附件上传dws合并为一个 to #666 2026-08-20 15:14:39 +08:00
赤川 15c075e6a6 Merge pull request #1068 from DingTalk-Real-AI/codex/changelog-v1.0.59-beta.5
chore: prepare v1.0.59-beta.5 changelog
2026-08-20 14:56:06 +08:00
chichuan f6d1e685e0 chore: prepare v1.0.59-beta.5 changelog 2026-08-20 14:52:30 +08:00
github-actions[bot] 81108e150b Merge pull request #1046 from xlb1130/feat/85614588-chat-personal-emotion
feat(chat): add personal emotion commands
2026-08-20 06:27:50 +00:00
xlb1130 dad9aefefa Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 14:08:05 +08:00
github-actions[bot] 71d49cb12b Merge pull request #1033 from pengzhihan47-star/codex/dingtalk-doc-skill-opt-v1
docs(skill): optimize dingtalk-doc workflows
2026-08-20 14:04:45 +08:00
柏智 f7e2efaaa2 ci: retrigger checks 2026-08-20 13:50:18 +08:00
pengzhihan47-star 557208e16b Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 13:31:31 +08:00
xlb1130 53401dbb0c Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 13:25:41 +08:00
github-actions[bot] 6c52ac37dd Merge pull request #1066 from DingTalk-Real-AI/codex/minutes-todo-wiki-param-aliases
feat(cli): expand Minutes TODO Wiki parameter aliases
2026-08-20 13:21:38 +08:00
xlb1130 95a17a3ffc Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 13:21:07 +08:00
pengzhihan47-star 3318741508 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 13:01:04 +08:00
克谨 3d7ab2690c feat(cli): expand Minutes TODO Wiki parameter aliases 2026-08-20 12:45:19 +08:00
github-actions[bot] 17eefcd24b Merge pull request #1064 from DingTalk-Real-AI/codex/fix-1060-schema-lineage
fix(policy): preserve historical Schema migration lineage
2026-08-20 04:29:42 +00:00
xlb1130 6f62ce7997 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 12:17:24 +08:00
赤川 2228a32d1a Merge branch 'main' into codex/fix-1060-schema-lineage 2026-08-20 12:11:55 +08:00
github-actions[bot] a6f79e951b Merge pull request #1050 from DingTalk-Real-AI/codex/fix-cli-eval-functional
fix: harden shortcut functional workflows
2026-08-20 04:08:39 +00:00
长真 096dfd48f0 docs(chat): keep emotion skill route within budget 2026-08-20 11:57:55 +08:00
chichuan 3922970bfc fix(policy): preserve schema migration lineage 2026-08-20 11:52:00 +08:00
Dennis4477 9b0441ca56 Merge branch 'main' into codex/fix-cli-eval-functional 2026-08-20 11:47:08 +08:00
xlb1130 2ab0edd5c6 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 11:45:41 +08:00
pengzhihan47-star 4b3272bcd4 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 11:42:41 +08:00
github-actions[bot] b6eaf3c5af chore: update beta formula for v1.0.59-beta.4 [skip ci] 2026-08-20 03:42:00 +00:00
长真 80d5d24637 Revert "docs(chat): trim chat skill context budget"
This reverts commit c5951a10ff.
2026-08-20 11:39:50 +08:00
柏智 e40397e239 docs(skill): restore bounded doc guidance 2026-08-20 11:34:14 +08:00
xlb1130 15bc7fdc3f Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 11:27:40 +08:00
柏智 da049be58d docs(skill): require terminal evidence for doc writes 2026-08-20 11:21:58 +08:00
柏智 6ec64e8a03 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 11:09:24 +08:00
柏智 bca56cbba6 Merge remote-tracking branch 'origin/codex/dingtalk-doc-skill-opt-v1' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 11:09:19 +08:00
赤川 aa4ae9a903 Merge pull request #1063 from DingTalk-Real-AI/codex/fix-996-multi-profile-skill-path
fix(ci): align multi-profile skill paths with canonical setup
2026-08-20 10:53:27 +08:00
chichuan 7a019c6fa3 fix(ci): align multi-profile skill paths 2026-08-20 10:46:17 +08:00
昭逸 103b05413e 审批附件相关dws help补充 to #666 2026-08-20 10:40:35 +08:00
john bb48aa0cc8 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 10:37:44 +08:00
柏智 6ffb4bcb93 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 10:37:30 +08:00
赤川 e2e4d6fc22 Merge pull request #1062 from DingTalk-Real-AI/codex/changelog-v1.0.59-beta.4
chore: prepare v1.0.59-beta.4 changelog
2026-08-20 10:30:46 +08:00
chichuan 2c0d6e4118 chore: prepare v1.0.59-beta.4 changelog 2026-08-20 10:25:13 +08:00
昭逸 169bbe88c0 上传附件dws to #666 2026-08-20 10:24:35 +08:00
pengzhihan47-star 08595594d7 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 10:15:26 +08:00
github-actions[bot] 379f625ca9 Merge pull request #1045 from DingTalk-Real-AI/codex/attendance-mail-shortcuts
feat(shortcut): harden Attendance and Mail workflows
2026-08-20 02:07:16 +00:00
anxiangbo 30782020ad Merge branch 'main' into feat/20260817_agoal_search 2026-08-20 10:03:21 +08:00
柏智 540bbac35b Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 09:57:23 +08:00
赤川 9d27313f5e Merge branch 'main' into codex/attendance-mail-shortcuts 2026-08-20 09:47:51 +08:00
github-actions[bot] cc86d1e958 Merge pull request #1043 from guimingyue/oa_approval_list_by_admin
feat(oa): add approval list-by-admin with string time contract
2026-08-20 01:47:23 +00:00
mygui 5619cb150e Merge branch 'main' into oa_approval_list_by_admin 2026-08-20 09:28:43 +08:00
柏智 c4d5595ca9 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 02:09:49 +08:00
github-actions[bot] 5aaf2efb59 Merge pull request #1060 from DingTalk-Real-AI/codex/govern-command-path-migrations
ci: govern Help and Schema command migrations
2026-08-20 02:04:43 +08:00
柏智 86d1eb8030 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 22:47:50 +08:00
柏智 ab529e5ee5 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 22:05:02 +08:00
xlb1130 15cb1f4311 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 22:01:44 +08:00
长真 97ca00868f test(chat): cover personal emotion user resolution 2026-08-19 21:51:49 +08:00
xlb1130 df8885c350 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 21:06:35 +08:00
赤川 d87cdef00b Merge branch 'main' into codex/fix-event-shutdown-lifecycle 2026-08-19 20:04:42 +08:00
赤川 f79c066806 Merge branch 'main' into oa_approval_list_by_admin 2026-08-19 19:51:24 +08:00
柏智 4e27a3a84a Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 19:45:13 +08:00
xlb1130 8685464c53 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 19:28:52 +08:00
mygui a0495c169b Merge branch 'main' into oa_approval_list_by_admin 2026-08-19 18:50:35 +08:00
长真 26b5939f9f chore(ci): retrigger pr checks 2026-08-19 18:34:38 +08:00
柏智 95bcace6bd Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 18:10:22 +08:00
mingyue.gmy 20c901d7ae fix(oa): require processCode in list-by-admin --request payloads
- Reject --request payloads with a missing, empty, or non-string
  processCode; the backend answers a bad processCode with success:true
  and an empty list, so validate client-side like startTime
- Add regression cases to keep changed-code coverage at 100%
2026-08-19 18:06:27 +08:00
mingyue.gmy e51ecc04f1 ci: trigger workflow rerun 2026-08-19 17:09:14 +08:00
长真 ce57cdf260 chore(ci): retrigger pr checks 2026-08-19 16:26:32 +08:00
Dennis 17741851f5 test: satisfy native shortcut coverage gate 2026-08-19 16:20:04 +08:00
anxb 999e7a7b9d feat: agoal新增dws2 2026-08-19 15:58:28 +08:00
Dennis d10446bea7 ci: reuse preinstalled archive tooling 2026-08-19 15:50:03 +08:00
anxb 94cee4388e Merge remote-tracking branch 'refs/remotes/origin/main' into feat/20260817_agoal_search 2026-08-19 15:38:58 +08:00
xlb1130 3ec138ba99 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 15:14:09 +08:00
anxb dcc7e72ec1 feat: agoal新增dws 2026-08-19 15:05:21 +08:00
Dennis 0ed05a2c5d fix: address shortcut review edge cases 2026-08-19 14:46:27 +08:00
Dennis ae1edefee6 test: cover shortcut hardening branches 2026-08-19 14:46:23 +08:00
Dennis 6dbc7ed82b fix: harden shortcut functional workflows 2026-08-19 14:46:19 +08:00
mygui 586ad0a5df Merge branch 'main' into oa_approval_list_by_admin 2026-08-19 14:41:40 +08:00
Dennis 6d88c9968e docs(attendance): clarify schedule availability 2026-08-19 14:37:38 +08:00
Dennis 36c61fd8ac fix(attendance): withhold unverifiable schedule query 2026-08-19 14:37:35 +08:00
Dennis 272b6b8a70 test(shortcut): lock public catalog count 2026-08-19 14:37:33 +08:00
Dennis c3328411c9 fix(shortcut): close mail review and schema compatibility 2026-08-19 14:37:31 +08:00
Dennis 83cfd10416 docs(shortcut): record final live review evidence 2026-08-19 14:37:29 +08:00
Dennis 9d43a12e08 fix(shortcut): address Attendance and Mail review findings 2026-08-19 14:37:27 +08:00
Dennis 7900e27946 fix(shortcut): preserve CLI compatibility for unavailable leaves 2026-08-19 14:37:25 +08:00
Dennis 42f54832b0 docs(shortcut): refresh rebased evidence references 2026-08-19 14:37:23 +08:00
Dennis 5f6ca90821 docs(shortcut): sync live evidence and generated lists 2026-08-19 14:37:21 +08:00
Dennis cad437aabd fix(attendance): filter validated record overfetch 2026-08-19 14:37:19 +08:00
Dennis 47d71375f6 fix(attendance): align live identity and availability 2026-08-19 14:37:17 +08:00
Dennis 69540c3372 fix(mail): preserve shortcut query schema property 2026-08-19 14:37:15 +08:00
Dennis 3a2b738c06 fix(shortcut): close attendance and mail release gates 2026-08-19 14:37:13 +08:00
Dennis 725e60f07c feat(mail): harden and align shortcut workflows 2026-08-19 14:37:11 +08:00
Dennis 8b4453adf9 feat(attendance): harden shortcut contracts and live evidence 2026-08-19 14:37:09 +08:00
柏智 f419c0f96d Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 14:37:01 +08:00
xlb1130 17101a8901 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 13:41:20 +08:00
柏智 66aa00fb50 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 12:23:42 +08:00
长真 9d6e151a6f Merge remote-tracking branch 'upstream/main' into feat/85614588-chat-personal-emotion 2026-08-19 10:54:30 +08:00
柏智 0df41d3eff Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 10:40:45 +08:00
mygui ab0d1d2ad6 Merge branch 'main' into oa_approval_list_by_admin 2026-08-19 10:17:51 +08:00
柏智 2a1ed8cc7a Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 10:09:48 +08:00
mingyue.gmy 358e1ab065 fix(oa): require startTime in --request and validate endTime independently
- Reject --request payloads missing startTime (documented required) so
  endTime can no longer bypass validation when startTime is absent
- Align --request time ordering with simple mode: endTime must be
  strictly after startTime
- Cover all five previously uncovered branches (pageSize absent,
  startTime absent, malformed endTime, valid time pair, empty --start
  flag) to reach 100% changed-code coverage
2026-08-19 10:03:29 +08:00
长真 c5951a10ff docs(chat): trim chat skill context budget 2026-08-19 00:55:27 +08:00
长真 3dbd29ab50 feat(chat): add personal emotion commands 2026-08-18 23:59:15 +08:00
柏智 1b50c7a5b4 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 23:25:57 +08:00
mingyue.gmy fab84af434 docs(changelog): add release fragment for oa approval list-by-admin 2026-08-18 21:32:00 +08:00
mingyue.gmy 2dd724f1e1 feat(oa): add approval list-by-admin with string time contract
- Add dws oa approval list-by-admin leaf with simple flags and
  advanced --request modes backed by get_process_instances_by_admin
- Send startTime/endTime as yyyy-MM-dd HH:mm:ss strings per the
  2026-08 MCP contract update; ISO-8601 flag inputs auto-convert
- Enforce pageSize cap (20) and string time format/order client-side;
  PreRunE reports flag-group violations in Chinese before Cobra's
  built-in English validation
- Extend coverage tests and document the command in mono/multi OA
  skill references
2026-08-18 21:10:23 +08:00
柏智 cbd70d1b88 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 21:03:52 +08:00
柏智 0ae8949d40 fix(doc): align skill contracts with runtime 2026-08-18 20:53:02 +08:00
pengzhihan47-star 0975d970d1 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 20:36:25 +08:00
柏智 7808673431 fix(doc): align media receipt contract 2026-08-18 20:31:07 +08:00
pengzhihan47-star 50ed921ca1 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 19:28:45 +08:00
pengzhihan47-star b34c29ec35 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 18:41:09 +08:00
pengzhihan47-star 97e5ded043 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 13:49:34 +08:00
pengzhihan47-star 7ceeafbae8 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 12:27:08 +08:00
pengzhihan47-star 54b4a24a14 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 11:35:30 +08:00
pengzhihan47-star e1bfb343f4 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 10:13:27 +08:00
柏智 7da423bf3c docs(skill): clarify import and recent document routes 2026-08-18 09:37:47 +08:00
柏智 fa83ee579c docs(skill): remove lark-specific wording 2026-08-18 08:25:06 +08:00
pengzhihan47-star 25c694aa2a Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 07:46:06 +08:00
柏智 e064d394ba docs(skill): optimize dingtalk doc workflows 2026-08-18 01:02:37 +08:00
炳昱 5a001f33b6 fix(event): clean up shutdown lifecycle 2026-08-13 17:34:20 +08:00
352 changed files with 40446 additions and 5641 deletions
@@ -0,0 +1,6 @@
---
category: Added
---
- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification.
- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads.
@@ -0,0 +1,5 @@
---
category: Changed
---
- **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts.
@@ -0,0 +1,24 @@
---
category: Changed
---
- **Permission error guidance and error rendering** (#1085) —
permission-denied responses now exit with the `AUTH_PERMISSION_DENIED` code
instead of a generic business-error rendering; document/wiki-specific errors
(the drive-specific codes `forbidden.accessDenied` / `forbidden.no.auth`,
or the role-threshold wording like
“需要您具备 MANAGER 及以上角色”) carry apply-permission guidance
(`dws drive permission apply-info` / `dws drive permission apply`), while
permission failures carrying only generic code names (`FORBIDDEN`,
`NO_PERMISSION` — also returned by attendance and event-subscription tools)
or other products' wording keep their product-specific or
product-neutral suggestion instead of a misleading document-permission hint;
member-validation failures such as
“用户不存在/不属于当前组织” are classified as tool errors with a
`--members`-with-`corpId` suggestion instead of a misleading
resource-not-found error; business error output now surfaces the backend
message with `code`/`logId` appended for traceability; and the
`update_permission` / `remove_permission` / `update_member` /
`remove_member` tools — whose servers return a literal `null` on successful
no-payload writes — now render `{}` so downstream JSON consumers do not fail
parsing `null`; other tools keep raw `null` output unchanged.
@@ -0,0 +1,22 @@
---
category: Added
---
- **Permission and member list pagination** (#1085) — `drive/doc permission
list` and `wiki member list` now accept `--next-token` to follow the
server-side cursor (output carries `totalCount`/`hasMore`/`nextToken`) and
map `--limit` to `pageSize` capped at 50 instead of the rejected `maxResults
200` path; `permission add/update/remove` and `wiki member add/update/remove`
additionally accept a `--members` JSON array covering USER/DEPT/CONVERSATION/TAG
grantee types. The optional `--notify` defaults to `false` and is omitted from
the server request unless passed explicitly, so member grants no longer notify
recipients by default. These commands also declare cursor pagination
(`next-token`) in the Agent schema contract, mirroring the internal CLI parity
change. Because a single batch remove can revoke access for up to 30
USER/DEPT/CONVERSATION/TAG members — where departments, chats, and role
groups can indirectly affect many more users — `drive/doc permission
remove` and `wiki member remove` now declare
`confirmation=user_required` and gate the actual tool call behind user
confirmation (`--yes`, an interactive yes, or `--dry-run` preview); their
confirmation-gate failure now also passes through verbatim instead of being
reclassified as a permission-denied or unclassified error.
+3 -1
View File
@@ -25,7 +25,9 @@ category: Added
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
将未归档 fragments 汇总为唯一的版本章节,并移动到
`.changes/released/<version>/`。因此 release-seal PR 是唯一会修改
`.changes/released/<version>/`。beta 发布后若有新 fragments 合入并直接准备 stable,
stable 封板会把它们追加到明确的 post-beta 小节,并归档到正式版本目录;没有新
fragments 时仍只生成原有 beta 晋级模板。因此 release-seal PR 是唯一会修改
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
删除或重写已归档文件。
@@ -0,0 +1,5 @@
---
category: Added
---
- **Agoal scorecard search-entities** — `dws agoal scorecard search-entities` searches scorecard metrics and key items by keyword, returning matching entity info (scorecard ID, entity ID, entity type, title, owning team) with optional `--page`/`--page-size` pagination.
+5
View File
@@ -0,0 +1,5 @@
---
category: Added
---
- **AITable datasource shortcuts** — adds 7 shortcuts for datasource sync management (`+datasource-create`, `+datasource-update`, `+datasource-sync`, `+datasource-sync-status`, `+datasource-get-config`, `+datasource-list-sources`, `+datasource-get-fields`) and updates the `dingtalk-aitable` skill with routing rules and a new `aitable-datasource.md` reference guide.
@@ -0,0 +1,13 @@
---
category: Added
---
- **Doc public-link and historical-version reads** — `dws doc read` forwards
the reviewed `password` (internet-public documents with password protection)
and `historyVersion` (read content as of a listed historical version; `0`
denotes the document's initial version) parameters on the markdown, JSONML,
and scope read paths via `--password` / `--version`; `dws doc +fetch` gains
`--password` and `--version` with the same `historyVersion` forwarding, while
`--revision` stays rejected with explicit guidance: revision is the document
edit revision returned by JSONML reads for `+update --expected-revision`
conditional writes, not a historical version number.
@@ -0,0 +1,5 @@
---
category: Added
---
- **OA approval attachment upload** — `dws oa approval attachment upload --file <path>` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted.
@@ -0,0 +1,14 @@
---
category: Changed
---
- **Attendance and Mail Shortcuts** (#1045) — publishes only capabilities with
strict response, identity, pagination, and real-data verification while
retaining historical CLI discovery and argument compatibility for commands
that remain unavailable to agents. Mailbox auto-resolution now accepts both
reviewed string and object response shapes, and Attendance date ranges cover
the complete requested end date without dropping cross-midnight punches whose
actual check time is inside the requested range. The schedule query remains
CLI-compatible but is withheld from the Agent catalog because its downstream
service returns a successful process exit with a null body for both populated
and empty ranges.
@@ -0,0 +1,5 @@
---
category: Added
---
- **OA admin approval query** — `oa approval list-by-admin` queries approval instances of a template with admin scope, with simple flags and an advanced `--request` mode; `startTime`/`endTime` use `yyyy-MM-dd HH:mm:ss` strings per the 2026-08 MCP contract update (ISO-8601 flag inputs auto-convert), and pageSize/time format are validated client-side with localized errors.
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Shortcut functional workflows** (#1050) — fixes truthful Drive push/sync previews, strict AITable write verification and deletion accounting, lossless Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
@@ -0,0 +1,7 @@
---
category: Fixed
---
- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel.
- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures.
- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC.
@@ -0,0 +1,5 @@
---
category: Changed
---
- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence.
@@ -0,0 +1,11 @@
---
category: Fixed
---
- **OAuth refresh falls back to the organization mirror** — when the server rejects the
current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound`
business code, `dws` now retries once with the still-valid token mirrored in the same
organization's slot (same corp, matching or backfilled user identity) before giving up,
and writes the rotated credential back to both the identity and the organization slots so
the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP
rejections without a reviewed business code do not trigger the fallback.
@@ -0,0 +1,5 @@
---
category: Changed
---
- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes.
+5
View File
@@ -0,0 +1,5 @@
---
category: Added
---
- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets.
+49 -9
View File
@@ -534,7 +534,8 @@ jobs:
# where the Schema partition alone owned most of the wall clock. The
# app-<partition> names are pinned to the helper's partition set by
# TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose
# its job silently.
# its job silently. The CrossPlatformCoverage-heavy C range is split again
# to retain headroom on runners reclaimed near the five-minute mark.
timeout-minutes: 20
strategy:
fail-fast: false
@@ -542,7 +543,11 @@ jobs:
shard:
- app-schema
- app-a-b
- app-c
- app-c-a-l
- app-c-m-o
- app-c-p-r
- app-c-s-z
- app-c-other
- app-d-r
- app-s-z-example-fuzz
- generators
@@ -617,7 +622,13 @@ jobs:
- name: Install archive tooling
if: ${{ matrix.shard == 'release-scripts' && steps.select.outputs.affected == 'true' }}
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Test shard with Race Detection
if: ${{ steps.select.outputs.affected == 'true' }}
@@ -672,7 +683,8 @@ jobs:
# partitions run concurrently and each releases its framework registries
# when the process exits; cli/smoke need headroom beyond go test -timeout for
# setup + assembly. The app-<partition> names are pinned to the helper's
# partition set by TestCIAppRacePartitionMatrixMatchesHelper.
# partition set by TestCIAppRacePartitionMatrixMatchesHelper. The
# CrossPlatformCoverage-heavy C range is split again for runner headroom.
timeout-minutes: 20
strategy:
fail-fast: false
@@ -680,7 +692,11 @@ jobs:
shard:
- app-schema
- app-a-b
- app-c
- app-c-a-l
- app-c-m-o
- app-c-p-r
- app-c-s-z
- app-c-other
- app-d-r
- app-s-z-example-fuzz
- generators
@@ -757,7 +773,13 @@ jobs:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Test release scripts
shell: bash
@@ -1135,7 +1157,13 @@ jobs:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Build
run: make build
@@ -1186,7 +1214,13 @@ jobs:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Run policy and shortcut coverage
run: |
@@ -1267,7 +1301,13 @@ jobs:
- name: Install archive tooling
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Run baseline unit tests with coverage
if: steps.baseline-cache.outputs.cache-hit != 'true'
+15 -9
View File
@@ -14,9 +14,12 @@ jobs:
uses: actions/github-script@v7
with:
script: |
const webhook = process.env.DINGTALK_WEBHOOK;
if (!webhook) {
console.log('⚠️ DINGTALK_WEBHOOK not set, skipping notification');
const webhooks = [
process.env.DINGTALK_WEBHOOK,
process.env.DINGTALK_WEBHOOK_SECONDARY
].filter(Boolean);
if (webhooks.length === 0) {
console.log('⚠️ No DingTalk webhook configured, skipping notification');
return;
}
@@ -39,12 +42,15 @@ jobs:
}
};
await fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message)
});
await Promise.all(webhooks.map(webhook =>
fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message)
})
));
console.log('✅ DingTalk notification sent');
console.log(`✅ DingTalk notification sent to ${webhooks.length} webhook(s)`);
env:
DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }}
DINGTALK_WEBHOOK_SECONDARY: ${{ secrets.DINGTALK_WEBHOOK_SECONDARY }}
+4 -2
View File
@@ -2698,9 +2698,11 @@ jobs:
;;
compatibility)
test -n "$PREVIOUS_STABLE"
./scripts/policy/check-command-compatibility.sh \
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh" \
--repo-root "$GITHUB_WORKSPACE" \
--base-ref HEAD \
--stable-ref "$PREVIOUS_STABLE"
--stable-ref "$PREVIOUS_STABLE" \
--candidate-ref HEAD
;;
e2e)
bash scripts/dev/test-multi-profile-e2e.sh
+81
View File
File diff suppressed because one or more lines are too long
+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.59-beta.3"
version "1.0.60-beta.1"
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.59-beta.3/dws-darwin-arm64.tar.gz"
sha256 "9c99adcefd9104368eb443f0a1b4af8e7aceaa1ffdd4462e486854c1692bb6ce"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-arm64.tar.gz"
sha256 "8ef11c79b5c86ec275dd82334232e7582f9e2ba99a66307d7681e42e8f53767b"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-amd64.tar.gz"
sha256 "f5cc8efb1f982d68ae549190fd683292359c2ab542b532fa52bb35e6b5c049af"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-amd64.tar.gz"
sha256 "67612f1dac735984b026c7f8a0dc057beec4cdd029f0a97798bf90aa923eb2d3"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-arm64.tar.gz"
sha256 "7a4efd04b417ce8013b1e431274b396179958da244164f59974358ba327ff093"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-arm64.tar.gz"
sha256 "67a8d4f4e0a7d22a9cc53cb91d8c97ecd1152665ce669f68560d86cec5987dd2"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-amd64.tar.gz"
sha256 "90181e8f2e9010c1943a5773c3d45d7d3ac85d6bc93e18a9aaa7c69909e553d7"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-amd64.tar.gz"
sha256 "a5fae548b495842779df4291cbcf06d8a2e5ddddf68a41cad1bab1e5c64a1d59"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-skills.zip"
sha256 "e7028914a4a826af9b18ed4922d68fa8f279473817fed4f305465bc8a7aad363"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-skills.zip"
sha256 "9fe12683139a626d32a801dd44158a698f142b61339282e0fc24d4e3a5e97e87"
end
def install
+11 -11
View File
@@ -1,33 +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.58"
version "1.0.59"
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.58/dws-darwin-arm64.tar.gz"
sha256 "7d98599f90cae9d42b51ff2863efc87dbfb4a3176ff3c84fc2216110c0157a70"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-arm64.tar.gz"
sha256 "61135a2a9286204ce060847e653c63c1e9784a0fa631bb7e0563b90628762a35"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-amd64.tar.gz"
sha256 "4c12e35e5bf7e0905812cd42dc94a5345068a2c16e306bb50b13c5c78b5cb95d"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-amd64.tar.gz"
sha256 "fd14b0b1a1475891fb243bf6453857a1044ab5a40bcf7dc1c7c795f57e5b03ba"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-arm64.tar.gz"
sha256 "5ef6bde24bc3db6a11a0f1d0b3343a048956b2cbcf6cd3409a037fb6ba425489"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-arm64.tar.gz"
sha256 "5bfe9ac7d1798b028f0fad579bbdffec5898e2fb16ee36f5766ab58e208abd50"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-amd64.tar.gz"
sha256 "3ccadcc6f070a39d2b2ba20429a4fcdc2f21639bf79f34361dc7d16f501bfda6"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-amd64.tar.gz"
sha256 "be1eb9a1f8fc5048e578b5b0bde212fc90baca0f289236c7c333d824bd869cf3"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-skills.zip"
sha256 "2626debc21c3daadfd155b4c167b2219b97e801398fe4441a8b48138960ab264"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-skills.zip"
sha256 "7ce5c3ab6f6a367407f64971bc5ff96cfcdfade2c1a10d326144b17c7b25a57e"
end
def install
+6 -2
View File
@@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
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 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 skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-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 shortcut-public-e2e-proof 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 skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema 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
@@ -20,6 +20,7 @@ help:
@printf " make test - Run the Go test suite\n"
@printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\n"
@printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n"
@printf " make shortcut-public-e2e-proof - Prove every reviewed Devdoc/HRbrain/PAT public Shortcut through exact and owning raw execution\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"
@@ -62,6 +63,9 @@ test-auth-legacy-compat:
@mkdir -p "$(POLICY_GOTMPDIR)"
@GO="$(GO)" $(POLICY_ENV) ./scripts/policy/check-auth-legacy-compat.sh
shortcut-public-e2e-proof: build
@GO="$(GO)" DWS_PACKAGE_VERSION="$(DWS_PACKAGE_VERSION)" ./scripts/policy/check-shortcut-public-e2e-proof.sh
lint:
@./scripts/dev/lint.sh
@@ -84,7 +88,7 @@ fmt:
$(GO_SOURCE_LIST) > "$$go_files"; \
xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files"
policy: test-auth-legacy-compat
policy: test-auth-legacy-compat shortcut-public-e2e-proof
@mkdir -p "$(POLICY_GOTMPDIR)"
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
+1 -1
View File
@@ -19,7 +19,7 @@
</p>
> [!IMPORTANT]
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
> **钉钉 DWS CLI 已全面开放,欢迎使用**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
>
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
+63 -5
View File
@@ -13,13 +13,71 @@ if (!fs.existsSync(binaryPath)) {
process.exit(1);
}
const result = childProcess.spawnSync(binaryPath, process.argv.slice(2), {
// Interactive commands must remain in the terminal's foreground session so
// prompts can use /dev/tty. Non-interactive launches use a separate process
// group, allowing a signal sent only to this wrapper to reach the full vendor
// process tree exactly once.
const isolateVendorProcessGroup = process.platform !== "win32" && !process.stdin.isTTY;
const child = childProcess.spawn(binaryPath, process.argv.slice(2), {
stdio: "inherit",
detached: isolateVendorProcessGroup,
});
if (result.error) {
console.error(result.error.message);
process.exit(1);
let spawnFailed = false;
let forwardedSignal = null;
const forwardedSignals = ["SIGINT", "SIGTERM"];
function forwardSignal(signal) {
forwardedSignal = signal;
if (child.exitCode === null && child.signalCode === null) {
if (process.platform === "win32") {
child.kill(signal);
return;
}
if (!isolateVendorProcessGroup) {
// Ctrl-C is generated for the whole foreground process group, including
// the vendor. SIGTERM is not terminal-generated and still needs an
// explicit handoff when a process manager targets only this wrapper.
if (signal === "SIGTERM") {
child.kill(signal);
}
return;
}
try {
// detached makes the vendor PID the leader of its POSIX process group.
// Signal the whole group so any subprocesses inherit the same shutdown.
process.kill(-child.pid, signal);
} catch (error) {
// The group may have completed between the state check and kill.
if (error.code !== "ESRCH") {
throw error;
}
}
}
}
process.exit(result.status === null ? 1 : result.status);
const signalHandlers = new Map(
forwardedSignals.map((signal) => [signal, () => forwardSignal(signal)]),
);
for (const signal of forwardedSignals) {
process.on(signal, signalHandlers.get(signal));
}
child.on("error", (error) => {
spawnFailed = true;
console.error(error.message);
});
child.on("close", (code, signal) => {
for (const forwarded of forwardedSignals) {
process.removeListener(forwarded, signalHandlers.get(forwarded));
}
const exitSignal = forwardedSignal || signal;
if (exitSignal && process.platform !== "win32") {
process.kill(process.pid, exitSignal);
return;
}
process.exitCode = spawnFailed || code === null ? 1 : code;
});
+55 -8
View File
@@ -1,6 +1,11 @@
# CLI Help / Schema 兼容迁移治理
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。
本文定义两种受控 flag 迁移:
1. `flag_rename`:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口;rename 必须保持原 flag 的 requiredness,optional 只能迁到 optional,required 只能迁到 required。
2. `requiredness_change`:同一个公开 flag 从 optional 精确提升为 required;flag 的名称、类型、作用域、可见性、shorthand、`no_opt` 与 alias 关系必须保持不变。
两种原语都只放行清单精确登记的变化,不是通用 breaking-change 豁免,也不得在同一 command/flag 上叠加以绕过 rename 的 requiredness 保持规则。
同一套 base-owned lifecycle 也治理两类跨命令迁移:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;或把旧命令中的一个可选 flag 拆成新的专用命令。跨命令迁移只允许清单精确声明的 `command_became_hidden` / `flag_became_hidden` 及其 Schema 投影,不是通用 command-path breaking-change 豁免。
@@ -44,7 +49,9 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
scripts/policy/interface-migrations/approved-command-migrations-v1.json
```
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。历史未声明 `kind` 的记录按 `flag_rename` 解释;新增同名 requiredness 迁移必须显式写 `kind: requiredness_change` 和单一 `flag` before/after。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
首次引入一个旧 merge-base 不认识的新 `kind` 时,机制 PR 不得同时写入该 kind 的 pending 记录,因为旧的 base-owned 严格解析器会拒绝未知字段。必须先合入 parser、lifecycle、CLI/Schema adapter 与 hostile tests;待这些实现成为新的 merge-base authority 后,再用独立治理审批 PR 新增 pending,最后才由产品 PR 消费。
## 跨命令迁移原语
@@ -68,6 +75,17 @@ optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。
`replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即
`true` 或固定 `false` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。
如果 `command_move` 的参数 `from` 在更早 stable 中仍使用另一历史名称,Schema adapter
只能把同一 legacy command 上、已经由 base-owned lifecycle 返回且
`state=consumed` 的 flag rename 回执作为前驱边。例如
`group → conversation-id` 与 `conversation-id → open-topic-id` 可以组合,但不能把
candidate 自增的 pending 记录、其他命令的同名参数、参数概念词典或 CLI alias 当作证据。
首次消费 pending command 回执时,merge-base 的 normalized Schema 必须真实发布中间参数,
并逐跳验证参数签名和 constraints;command 回执合入为 consumed 后,中间 Schema 已从 main
消失,此时保留的两份 consumed 回执可继续对 stable 做受限重放,直到 stable 也达到 after
并让回执转为惰性记录或由独立 PR 清理。两种阶段都拒绝残留 predecessor/intermediate、字段漂移、环、分叉、
target 碰撞或 primary path/tool identity 不唯一;positionals 不在该组合授权面内。
`replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot
必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、
值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create`
@@ -86,22 +104,22 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。
跨命令清单复用下文同一套 `pending → consumed → cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
跨命令清单复用下文同一套 `pending → consumed → inert/cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
当前首批 pending 记录覆盖 `chat topic` 收口:`chat group create --thread` 拆到 `chat topic create`,以及 `chat message list-topic-replies` / `forward-topic` 迁到对应的 `chat topic` 命令。前一条完整登记 `name` / `type` / `users` 的同名承接,以及 `thread` → `convThreadEnabled=true` 的常量承接。产品 PR 消费这些记录时只能把三条 `state` 改为 `consumed`,不得改写其 before、after、Schema mapping、constant 或 reason。
## 两阶段迁移与回执清理
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
rename 以 `(kind, command, legacy flag, canonical flag)` 为唯一精确键;requiredness change 以 `(kind, command, flag)` 为唯一精确键。二者经历同一生命周期:
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|---|---|---|
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | rename 的 legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`,canonical requiredness 保持不变;requiredness change 只把同名 flag 从 optional 提升为 required |
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
| 4. 惰性保留或清理 | 当 merge-base 和 stable 都已经是 `after`,该记录不再提供任何授权;后续 PR 可以原样保留或删除 | 两份参考快照均精确匹配 `after`;保留时仍必须是不可改写的 `consumed`,接口偏离 `after` 继续失败 |
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。stable 发布只会让已经追平的 `consumed` 回执变成无授权效果的审计记录,不会在没有代码变更时让后续业务 PR 失去合规性;清理仍可作为独立的账本压缩动作,但不再是下一个 PR 的强制前置条件。
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
@@ -146,6 +164,27 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。
同名 flag requiredness 迁移的清单结构如下;示例不代表已经审批:
```json
{
"version": 1,
"migrations": [
{
"kind": "requiredness_change",
"command": "dws report entry submit",
"flag": {
"name": "to-user-ids",
"before": {"present": true, "type": "string", "scope": "local"},
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
},
"state": "pending",
"reason": "Reject report submissions that have no visible recipient."
}
]
}
```
## `alias_of` 是框架来源的受评审关系证据
`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证:
@@ -166,10 +205,11 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
## 豁免边界
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下三类预期 finding:
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。
3. `requiredness_change` 中同名 flag 从 optional 提升为 required 时产生的 `flag_became_required`。
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
@@ -177,6 +217,7 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移;
- `alias_of` 缺失、指向变化或 alias chain;
- 命令路径及任何无关的阻塞性接口变化;
- requiredness change 同时发生的 rename、隐藏、类型、scope、shorthand、`no_opt` 或 alias 漂移;
- 不精确、部分完成、超出记录范围的 surface 变化。
## Schema 投影边界
@@ -204,6 +245,12 @@ adapter 先构造经过上述验证的历史 contract 副本,再调用原 Sche
canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker
验证 candidate 是否仍与该 canonical contract 兼容。
`requiredness_change` 的 Schema adapter 只把历史同名 parameter 的 `required` 与
`cli_required` 提升到 candidate 的 `true` 值,并要求 candidate 两者都为 `true`。parameter
不存在、tool/path 不匹配时不制造 Schema surface;type、property、interface type、default、
format、enum、`required_when`、constraints、positionals 与 safety 等全部字段仍交给原 checker,
任何不相干漂移继续阻塞。
## 本地验证
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
+6 -3
View File
@@ -3,7 +3,7 @@
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
- **Products**: 13
- **Total commands**: 160
- **Total commands**: 163
- **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
> Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
@@ -33,7 +33,7 @@ Every command inherits these flags (documented here once, not repeated per comma
- [`dws aitable` — AI Tables](#dws-aitable) · 41 commands
- [`dws attendance` — Attendance](#dws-attendance) · 4 commands
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
- [`dws chat` — Group Chat / IM](#dws-chat) · 26 commands
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
@@ -134,12 +134,15 @@ _Calendar events, participants, meeting rooms, and busy-status queries._
_Group chats, conversations, messages, and robot/webhook integrations._
**23 commands**
**26 commands**
| Command | Description | When to use |
|---|---|---|
| `dws chat bot search` | Search robots (bots) created by the current user by keyword. | When the agent needs to resolve one of its own bots by name to a robot code before sending bot messages. |
| `dws chat conversation-info` | Retrieve basic metadata for a conversation (single chat or group chat) by conversation ID. | When the agent needs context about a conversation (name, type, member count) before operating on it. |
| `dws chat emotion favorite` | Add a media ID to the current user's personal favorite emotions. | When the agent needs to save an available mediaId as a reusable personal emotion, optionally preserving source message context. |
| `dws chat emotion list` | List the current user's personal favorite emotions. | When the agent needs to inspect available personal emotions or resolve an emotionId/mediaId before sending. |
| `dws chat emotion send` | Send a personal favorite emotion to a group or direct chat as the authenticated user. | When the agent needs to send a known personal emotion mediaId to exactly one group, userId, or openDingTalkId target. |
| `dws chat group create` | Create a new internal group chat with a set of initial members. | When the agent needs to spin up a dedicated group for a new project, incident, or discussion thread. |
| `dws chat group members` | List members of a group chat; can also be used against the current user to enumerate their groups' members. | When the agent needs the roster of a group before mentioning, removing, or auditing members. |
| `dws chat group members add` | Add one or more users to an existing group chat. | When the agent expands a group to include additional participants. |
+454
View File
@@ -0,0 +1,454 @@
# AI 表格数据源指令使用指南
## 概述
dws 新增了 7 个 AI 表格数据源同步管理指令,用于将外部数据源(一期支持审批数据)接入 AI 表格,实现数据的自动同步。
所有指令均通过 `dws aitable +datasource-*` 前缀调用,操作对象是 AI 表格中的"数据源表"——一种由数据源同步创建的特殊数据表。
## 指令速览
| 指令 | 用途 | 读写 | 风险 |
|------|------|------|------|
| `+datasource-list-sources` | 列出数据源类型可用的来源信息(OA 返回 result/processCode、sourceType、sourceUrl) | 读 | low |
| `+datasource-get-fields` | 获取数据源来源的可同步字段结构 | 读 | low |
| `+datasource-create` | 创建数据源表并触发首次同步 | 写 | medium |
| `+datasource-update` | 更新已有数据源表的同步配置 | 写 | medium |
| `+datasource-sync` | 手动触发一次同步 | 写 | medium |
| `+datasource-sync-status` | 查询同步任务状态 | 读 | low |
| `+datasource-get-config` | 查看数据源表配置 | 读 | low |
## 前置条件
1. **登录认证**:执行 `dws auth login` 确保已登录
2. **获取 Base ID**:通过 `dws aitable +base-list` 或 `dws aitable +base-search --query "关键词"` 获取目标 AI 表格的 Base ID
---
## 1. 列出数据源可用来源
```
dws aitable +datasource-list-sources [flags]
```
列出指定数据源类型可用的来源信息。OA 审批类型返回当前 Base 可用的审批数据源条目(`sources` 数组,当前通常为单条),用于构造 `+datasource-create` / `+datasource-update` / `+datasource-get-fields` 的 `--source-config`。OA 场景下每条 source 的 `result` 字段是 JSON 字符串,需解析后得到 `approvals` 数组,再从中提取目标模板的 `processCode`、`name`、`iconUrl`、`url`。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
### 示例
```bash
# 列出审批数据源来源,获取 result(JSON,解析后得到 approvals[].processCode)
dws aitable +datasource-list-sources \
--base-id BASE123 \
--datasource-type OA
```
### 返回值
返回 `sources` 数组,每个条目包含:
| 字段 | 说明 |
|------|------|
| `result` | OA 审批场景为 JSON 字符串,解析后得到 `approvals` 数组;每个 approval 含 `processCode`、`name`、`iconUrl`、`url` |
| `sourceType` | 数据源类型编号(OA 对应内部枚举值 2) |
| `sourceUrl` | 数据源访问链接,可选 |
`result` 本身不是 `processCode`,需要解析出 `approvals` 数组,再取目标模板的 `processCode`、`name`、`iconUrl`、`url` 原样填入 `--source-config`。
---
## 2. 获取数据源可同步字段
```
dws aitable +datasource-get-fields [flags]
```
获取指定数据源来源(如某个审批模板)的可同步字段列表,包括字段 ID、字段名称、字段类型和是否主键等信息。用于创建数据源前选择需要同步的字段。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
| `--source-config` | string | 是 | 源配置 JSON 字符串,结构同 `+datasource-create` 的 `--source-config` |
### 示例
```bash
# 获取某审批模板的可同步字段
dws aitable +datasource-get-fields \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
```
### 返回值
返回可同步字段列表,每个字段包含字段 ID、名称、类型和是否主键。字段 ID 可用于 `+datasource-create` / `+datasource-update` 的 `--field-ids` 参数。
---
## 3. 创建数据源表
```
dws aitable +datasource-create [flags]
```
为指定 AI 表格创建数据源同步配置,自动创建一张数据源表并触发首次全量同步。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID(通过 `+base-list` / `+base-search` 获取) |
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
| `--source-config` | string | 是 | 源配置 JSON 字符串(格式见下方) |
| `--auto` | bool | 否 | 是否开启自动同步,默认 false;无论是否传入,CLI 都会把该字段下发给下游 |
| `--auto-sync-setting` | string | 否 | 自动同步频率配置 JSON 字符串,仅在 `--auto=true` 时生效,格式见下方 |
| `--field-ids` | stringSlice | 否 | 需要同步的字段 ID 列表,不传时同步全部字段 |
### source-config 格式(审批类)
审批数据源的 `--source-config` 是一个 JSON 对象字符串,包含以下字段:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `processCode` | string | 是 | 审批模板编码,对应 `+datasource-list-sources` 返回的 `result` |
| `name` | string | 是 | 数据源展示名称,须从 `+datasource-list-sources` 结果原样透传 |
| `iconUrl` | string | 是 | OA 审批图标 URL,须从 `+datasource-list-sources` 结果原样透传 |
| `url` | string | 是 | OA 审批跳转链接,须从 `+datasource-list-sources` 结果原样透传 |
| `dataType` | string | 是 | 数据时间范围类型:`time_range` / `start_time` / `recent_time` |
| `recentDays` | string | 当 dataType=recent_time 时必填 | 近 N 天:`7d` / `30d` / `1y` |
| `startDate` | string | 当 dataType=time_range 或 start_time 时必填 | 起始日期,格式 `yyyy-MM-dd` |
| `endDate` | string | 当 dataType=time_range 时必填 | 结束日期,格式 `yyyy-MM-dd` |
| `keepRemovedFields` | bool | 否 | 是否保留已删除字段,默认 false |
> 注:`splitParentTableField`、`enableDataSyncOaDetailList` 等字段为下游内部字段,无需传入,下游自动处理。
按 `dataType` 选择对应的时间参数组合:
| dataType | 需要的时间字段 | 说明 |
|----------|----------------|------|
| `recent_time` | `recentDays` | 同步近 N 天数据(7d/30d/1y) |
| `start_time` | `startDate` | 同步从某日期至今的数据 |
| `time_range` | `startDate` + `endDate` | 同步指定日期范围内的数据 |
### auto-sync-setting 格式
`--auto-sync-setting` 仅在 `--auto=true` 时生效,用于指定自动同步频率。不传时使用下游默认策略。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `syncType` | string | 是 | `hourly`(按小时间隔)/ `scheduled`(定时触发) |
| `hourlyInterval` | int | hourly 时必填 | 正整数,小时间隔 |
| `scheduleType` | string | scheduled 时必填 | `daily` / `weekly` / `monthly` |
| `timeValue` | string | scheduled 时必填 | 触发时间,格式 `HH:mm` |
| `selectedMonthDays` | int[] | monthly 时必填 | 每月几号触发,1-31 |
| `selectedWeekdays` | int[] | weekly 时必填 | 每周哪几天触发,1=周一…7=周日 |
| `skipNonWorkingDay` | bool | 否 | 是否跳过非工作日,默认 false |
示例:`{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`
### 示例
```bash
# 基本创建——同步近 30 天审批数据
dws aitable +datasource-create \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
# 指定日期范围创建并开启自动同步
dws aitable +datasource-create \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' \
--auto
# 指定同步字段(仅同步部分字段,field-ids 可通过 +datasource-get-fields 获取)
dws aitable +datasource-create \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' \
--field-ids fldAAA,fldBBB,fldCCC
```
### 返回值
创建成功后返回新建数据源表 ID 和同步任务 ID,后续操作需要用到这两个 ID。
---
## 4. 更新数据源配置
```
dws aitable +datasource-update [flags]
```
更新已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后会自动触发一次同步。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--table-id` | string | 是 | 已存在的数据源表 ID(由 `+datasource-create` 返回) |
| `--source-config` | string | 否 | 新的源配置 JSON 字符串,不传时保持原有配置。结构同 `+datasource-create` |
| `--auto` | bool | 否 | 是否开启自动同步;仅显式设置时下发给下游,省略时保持原有自动同步开关不变 |
| `--auto-sync-setting` | string | 否 | 自动同步频率配置 JSON 字符串,仅在显式设置 `--auto=true` 时生效;省略时保持原频率配置 |
| `--field-ids` | stringSlice | 否 | 需要同步的字段 ID 列表,不传时保持现有字段配置 |
### 示例
```bash
# 更换审批模板并调整时间范围
dws aitable +datasource-update \
--base-id BASE123 \
--table-id TBL456 \
--source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
# 开启自动同步
dws aitable +datasource-update \
--base-id BASE123 \
--table-id TBL456 \
--auto
# 更新同步字段范围
dws aitable +datasource-update \
--base-id BASE123 \
--table-id TBL456 \
--field-ids fldAAA,fldDDD
```
> 注意:`--table-id` 指向的是数据源表(由 `+datasource-create` 创建),不是普通数据表。
---
## 5. 触发手动同步
```
dws aitable +datasource-sync [flags]
```
对已有数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--table-ids` | stringSlice | 是 | 待触发同步的数据源表 ID 列表(1-5 个) |
### 示例
```bash
# 同步单张表
dws aitable +datasource-sync \
--base-id BASE123 \
--table-ids TBL1
# 批量同步多张表(逗号分隔,最多 5 个)
dws aitable +datasource-sync \
--base-id BASE123 \
--table-ids TBL1,TBL2,TBL3
```
### 返回值
返回每个表的同步任务 ID,可通过 `+datasource-sync-status` 查询最终结果。
---
## 6. 查询同步状态
```
dws aitable +datasource-sync-status [flags]
```
按任务 ID 查询数据源表的同步任务状态。与 `+datasource-sync` / `+datasource-create` / `+datasource-update` 配对使用——这些指令触发同步后返回任务 ID,本指令通过任务 ID 查询最终结果。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--table-id` | string | 是 | 数据源表 ID |
| `--task-ids` | stringSlice | 是 | 待查询的同步任务 ID 列表(1-5 个) |
### 示例
```bash
# 按任务 ID 查询(批量,最多 5 个)
dws aitable +datasource-sync-status \
--base-id BASE123 \
--table-id TBL456 \
--task-ids TASK1,TASK2
```
---
## 7. 获取数据源配置
```
dws aitable +datasource-get-config [flags]
```
获取指定数据源表的同步配置信息,包括源配置、同步模式、自动同步开关和同步状态。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--table-id` | string | 是 | 数据源表 ID |
### 示例
```bash
dws aitable +datasource-get-config \
--base-id BASE123 \
--table-id TBL456
```
---
## 典型工作流
### 场景一:从零接入审批数据
```bash
# 0. 获取 Base ID
dws aitable +base-search --query "我的项目表"
# 1. 列出可用审批数据源来源,解析 result JSON 获取 approvals[].processCode
dws aitable +datasource-list-sources \
--base-id BASE123 \
--datasource-type OA
# → 返回 sources[0].result 为 JSON 字符串,解析后取 approvals[0].processCode=PROC-XXXX
# 2. 查看可同步字段(可选,用于指定 field-ids)
dws aitable +datasource-get-fields \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
# 3. 创建数据源表(创建后自动触发首次同步)
dws aitable +datasource-create \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
# → 返回 tableId=TBL456, taskId=TASK001
# 4. 查询首次同步是否完成
dws aitable +datasource-sync-status \
--base-id BASE123 \
--table-id TBL456 \
--task-ids TASK001
# 5. 确认配置
dws aitable +datasource-get-config \
--base-id BASE123 \
--table-id TBL456
```
### 场景二:更换审批模板后重新同步
```bash
# 1. 更新源配置(更新后自动触发一次同步)
dws aitable +datasource-update \
--base-id BASE123 \
--table-id TBL456 \
--source-config '{"processCode":"PROC-NEW","name":"新审批模板","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
# 2. 查询同步状态(更新后会返回新的 taskId)
dws aitable +datasource-sync-status \
--base-id BASE123 \
--table-id TBL456 \
--task-ids TASK002
```
### 场景三:手动触发日常同步
```bash
# 仅触发同步,不修改配置
dws aitable +datasource-sync \
--base-id BASE123 \
--table-ids TBL456
# 查询结果(sync 会返回 taskId)
dws aitable +datasource-sync-status \
--base-id BASE123 \
--table-id TBL456 \
--task-ids TASK001
```
### 场景四:开启自动同步后确认
```bash
# 1. 更新配置,开启自动同步
dws aitable +datasource-update \
--base-id BASE123 \
--table-id TBL456 \
--auto
# 2. 确认配置已更新
dws aitable +datasource-get-config \
--base-id BASE123 \
--table-id TBL456
# → 返回中应显示 auto=true
```
---
## 通用选项
以下全局选项可在所有指令中使用:
| 选项 | 说明 |
|------|------|
| `-f, --format` | 输出格式:json(默认)/ table / raw / pretty / ndjson / csv |
| `--jq` | jq 表达式过滤输出(如 `.tableId` 或 `.status`) |
| `--fields` | 筛选输出字段(逗号分隔) |
| `--dry-run` | 预览操作内容,不实际执行 |
| `--profile` | 指定组织或账号 |
| `--timeout` | HTTP 请求超时时间(秒,默认 30) |
| `--debug` | 显示调试日志 |
| `-v, --verbose` | 显示详细日志 |
### 输出过滤示例
```bash
# 只取 tableId
dws aitable +datasource-create ... --jq '.tableId'
# 只取同步状态
dws aitable +datasource-sync-status ... --jq '.status'
# table 格式查看
dws aitable +datasource-get-config ... -f table
```
---
## 注意事项
1. **推荐流程**:先 `+datasource-list-sources` 解析 `result` JSON 获取 `approvals[].processCode`,再 `+datasource-get-fields` 查看可同步字段,最后 `+datasource-create` 创建数据源表。
2. **数据源表 vs 普通数据表**:`+datasource-create` 创建的是"数据源表",它由数据源同步驱动数据写入。`+datasource-update` 和 `+datasource-sync` 仅适用于数据源表,不可对普通数据表使用。
3. **datasource-type 透传**:CLI 层不对 `--datasource-type` 做枚举校验,目前一期仅支持 `OA`(审批)。后续支持其他类型时由服务端控制,CLI 无需修改。
4. **source-config 格式**:`--source-config` 必须是合法 JSON 字符串。审批数据源需要原样透传 `processCode`(从 `+datasource-list-sources` 返回的 `result` JSON 中解析 `approvals[]` 提取)、`name`、`iconUrl`、`url`,设置 `dataType`(时间范围类型),并按 `dataType` 提供对应的时间参数(`recentDays` / `startDate` / `endDate`)。
5. **同步限制**:`+datasource-sync` 单次最多 5 张表;`+datasource-sync-status` 单次最多查询 5 个任务 ID。
6. **创建即同步**:`+datasource-create` 和 `+datasource-update` 在操作完成后会自动触发一次同步,无需额外调用 `+datasource-sync`。
7. **自动同步**:`--auto` 开启后,数据源表会按 `--auto-sync-setting` 指定的频率自动定期同步;未指定频率时使用服务端默认策略。关闭 `--auto` 后仅能通过 `+datasource-sync` 手动触发。
+10 -5
View File
@@ -23,7 +23,7 @@
`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 全部成功。
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、CLI 与 Schema 兼容性、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 证明。
@@ -102,7 +102,7 @@ fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR
dws-release v1.2.3-beta.1
```
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
预检包含测试、策略检查、旧正式版 CLI 与 Schema 双基线兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
## 正式发布
@@ -140,14 +140,19 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
`.changes/<unique-name>.md` 中增加一个独立 fragment;格式和允许的分类见
[`.changes/README.md`](../.changes/README.md)。预发封板时
`scripts/release/prepare-changelog.sh prerelease <version>` 会稳定排序并汇总所有未归档
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。因此并发 PR 不会争用
`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。如果 beta 发布后又有
带 fragment 的 PR 合入,而维护者决定直接发布 stable,
`scripts/release/prepare-changelog.sh stable <version> --from-beta <tag>` 会保留 beta 晋级摘要
模板,并把这些 post-beta fragments 写到明确的 `Changes since <beta>` 边界之后,再移动到
`.changes/released/<stable-version>/`。没有 active fragment 时,stable 仍只生成原有晋级摘要
模板。因此并发 PR 不会争用 `CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与
归档移动,供审计复核。
## 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 必须由云端 seal job 创建为 annotated tag;封板提交必须已通过 PR 合入并包含在远端 `main` 历史中。流水线允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整 CLI 与 Schema 契约;若长时间预检期间该 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`,不会覆盖稳定入口。
+315 -241
View File
@@ -1,7 +1,17 @@
{
"generated_at": "2026-08-18T17:38:50.904696",
"count": 436,
"generated_at": "2026-08-24T12:22:05.108140",
"count": 426,
"results": [
{
"suite": "semantic",
"service": "aisearch",
"command": "+search-person",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "对 enterprise_person_search 增加显式 success/result 数组、坏元素、来源类型和稳定人员身份校验;exact live 已同时证明已知非空与 phone 维度不可存在号码的显式零命中。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
@@ -272,6 +282,76 @@
"semantic_delta": "更新仪表盘配置的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-get-config",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取已有数据源表的同步配置详情(源配置、字段选择、自动同步状态)的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-get-fields",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键),用于决定 field-ids。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-list-sources",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "列出指定 Base 下可用的数据源条目(OA 审批模板等),提取 processCode/name/iconUrl/url 用于 sourceConfig。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-sync",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-sync-status",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "更新已有数据源表的同步配置并触发一次同步;不改配置可只切换 auto 开关或 field-ids。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
@@ -933,137 +1013,84 @@
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "attendance",
"command": "+check-record",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "查询原始打卡流水;只有显式成功数组可表示空结果,每条必须有唯一正整数 ID 并绑定请求用户和日期。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "attendance",
"command": "+check-result",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "对齐 Lark attendance user_tasks query;严格校验正整数 ID 及请求用户/日期绑定,以 cursor_parameter=offset 将保守续页证据发布到 meta.pagination。",
"availability": "available"
},
{
"suite": "read",
"service": "attendance",
"command": "+get-adjustment-rule",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "attendance",
"command": "+get-approve-template",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "把补卡、请假、加班、外出、出差映射为审批模板类型;每项以非空唯一 processCode 作为稳定身份,approveType 精确绑定请求且 submitUrl 必须非空,允许同类型返回多个模板。",
"availability": "available"
},
{
"suite": "read",
"service": "attendance",
"command": "+get-checkin-record",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "attendance",
"command": "+get-leave-records",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "attendance",
"command": "+get-overtime-rule",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result,且响应 id 必须与请求 overtimeId 精确一致。",
"availability": "available"
},
{
"suite": "read",
"service": "attendance",
"command": "+get-schedule",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "attendance",
"command": "+get-self-setting",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "attendance",
"command": "+get-summary",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "attendance",
"command": "+list-approve",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "把审批类型语义映射为业务枚举;每条审批必须有唯一正整数 ID,并精确绑定请求用户、类型与时间范围。",
"availability": "available"
},
{
"suite": "read",
"service": "attendance",
"command": "+list-leave-types",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "attendance",
"command": "+my-attendance",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "attendance",
"command": "+query-report-data",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "attendance",
"command": "+search-adjustment-rule",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格读取 result.adjustmentList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "attendance",
"command": "+search-class",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格读取 result.items、展开 shiftVO、要求唯一正整数 classId,并将无矛盾的 totalCount/totalPage 续页证据发布到 meta.pagination。",
"availability": "available"
},
{
"suite": "read",
"service": "attendance",
"command": "+search-group",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "attendance",
"command": "+search-overtime-rule",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "attendance",
"command": "+this-month",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格读取 result.atRuleList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。",
"availability": "available"
},
{
"suite": "semantic",
@@ -2316,102 +2343,134 @@
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+by-mobile",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "使用专用手机号精确查询接口解析稳定 userId;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,未命中返回 typed nonzero;命中后读取并精确核对同一用户详情,null、错型、坏身份或详情 ID 不一致均失败。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+dept-members",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按部门名唯一解析 deptId 后列直属成员;搜索候选和成员集合均逐项严格校验,绝不猜测多匹配。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+list-dept-members",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按 deptId 列直属成员,严格要求显式 deptUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+list-followings",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格要求 success、result.models 数组、对象元素、唯一稳定 openDingTalkId;可用 --open-id 做本地精确筛选,exact live 已证明已知非空与保证零命中。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+list-role-members",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按角色列成员,严格要求 success、显式 labelUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。",
"availability": "available"
},
{
"suite": "read",
"service": "contact",
"command": "+list-roles",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+list-sub-depts",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按父部门列直属子部门,严格要求显式 result 数组与有效 deptId;已验证非空与随机零命中。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+lookup",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按姓名唯一解析稳定 userId 后读取并核对唯一用户详情;零命中和多命中均错误关闭。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+me",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "读取当前用户唯一详情并严格要求 orgEmployeeModel 与稳定 userId,再投影最小自身份字段。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+org",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按姓名解析用户、核对用户详情与主 deptId,再读取并核对部门详情的稳定身份。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+resolve-dept",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按名称返回唯一 deptId 或显式候选;严格要求 deptList 数组、有效且不重复的 deptId 与部门名。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+search-mobile",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "使用专用手机号精确查询接口取得稳定 userId,并直接投影该接口返回的受审身份字段,不额外依赖用户详情权限;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,null、空对象、数组或坏身份均失败。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+search-user",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按姓名搜索并严格要求 success、显式 result 数组、非空对象和稳定 userId/openDingTalkId;已验证非空与随机零命中。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+team",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按姓名解析用户和主部门后列直属成员;每一步校验 success、稳定身份及显式成员集合。",
"availability": "available"
},
{
"suite": "write",
@@ -2547,32 +2606,14 @@
"status": "real-ok"
},
{
"suite": "read",
"service": "ding",
"command": "+list",
"risk": "read",
"status": "real-ok"
},
{
"suite": "write",
"service": "ding",
"command": "+recall-personal",
"risk": "high-risk-write",
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "ding",
"command": "+receiver-status",
"risk": "read",
"status": "real-ok"
},
{
"suite": "write",
"service": "ding",
"command": "+send-personal",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。",
"availability": "available"
},
{
"suite": "semantic",
@@ -3305,74 +3346,84 @@
"availability": "available"
},
{
"suite": "read",
"service": "mail",
"command": "+contact-list",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "mail",
"command": "+find-mail-user",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "面向联系人解析的精简用户搜索;修复零命中被误报为调用失败,并严格验证用户数组、身份和分页。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "mail",
"command": "+folder-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "只接受显式 folders 数组及稳定 folder ID;缺字段、错型和坏元素不再退化为空列表。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "mail",
"command": "+recent-mail",
"command": "+message",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "对齐 Lark 单封完整读入口;自动解析邮箱,要求顶层 message 非空且返回 ID 与请求 messageId 精确一致。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "mail",
"command": "+messages",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "对齐 Lark 多封完整读入口;最多 100 个 ID,保持请求顺序,任何单封缺失、错型或身份不一致都会使整次调用失败。",
"availability": "available"
},
{
"suite": "semantic",
"service": "mail",
"command": "+search-mail",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "用 KQL 搜索邮件摘要;只接受已观测 messages 数组、稳定 messageId 和完整分页证据,窄化支持 Mail 专属 total=0 占位哨兵。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "mail",
"command": "+tag-list",
"command": "+thread",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "对齐 Lark 完整会话读取;自动解析邮箱并要求 conversation.id 与请求精确一致。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "mail",
"command": "+template-list",
"command": "+triage",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "对齐 Lark triage 任务入口,并超过其显式邮箱要求:可自动解析当前邮箱与收件箱,严格处理邮件搜索的终止游标和零命中哨兵。",
"availability": "available"
},
{
"suite": "read",
"service": "mail",
"command": "+thread-list",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "mail",
"command": "+unread-mail",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "mail",
"command": "+user-search",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "搜索企业邮箱用户并保留 hasMore/nextCursor;显式空数组合法,坏用户或缺稳定 ID 失败。",
"availability": "available"
},
{
"suite": "semantic",
@@ -3645,81 +3696,84 @@
"availability": "available"
},
{
"suite": "read",
"service": "oa",
"command": "+list-cc",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "oa",
"command": "+list-executed",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "oa",
"command": "+list-forms",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "oa",
"command": "+list-pending",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "oa",
"command": "+list-submitted",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "oa",
"command": "+my-initiated",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "oa",
"command": "+search-forms",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "pat",
"command": "+browser-policy",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "在隔离本地策略文件上提供显式确认、无写入请求预览、同目标磁盘读回和不暴露 agent identity 的统一结果;exact 写入与清理已通过。",
"availability": "available"
},
{
"suite": "semantic",
"service": "report",
"command": "+inbox-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;current HEAD exact 与 owning atomic 同场景已知页均为 20 项、稳定身份集合和 next cursor 一致,独立未来范围均为 0 且明确终止。终止页回显 cursor 只作已验证收据且不发布 next_token。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "report",
"command": "+outbox-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;current HEAD exact 与 owning atomic 同场景已知页均为 1 项且身份一致,独立未来范围均为 0 并明确终止。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "report",
"command": "+report-latest",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;current HEAD exact 所选稳定 reportId 与 owning atomic 候选和精确详情身份一致,严格详情字段计数双层均为 3。",
"availability": "available"
},
{
"suite": "semantic",
"service": "report",
"command": "+template-search",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;current HEAD exact 与 owning atomic 完整集合过滤的已知结果均为 1 且身份一致,随机 UUID 查询均为 0。",
"availability": "available"
},
{
"suite": "semantic",
"service": "sheet",
"command": "+list-sheets",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格要求 success=true、显式 sheets 数组、非空且唯一的 sheetId 与标题;提供完整标题本地精确筛选,因此可分别证明已知非空和合法零命中,未知结构绝不降级为空数组。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "sheet",
"command": "+read",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格校验 success、二维 cells、行列坐标与完成证据;服务返回 hasMore=true 时因没有可执行续页游标而失败关闭,保留 Sheet 读取与 AITable/Base 记录查询的产品边界。",
"availability": "available"
},
{
"suite": "semantic",
@@ -3931,6 +3985,26 @@
"semantic_delta": "更新指定字段后读取详情逐字段核验。",
"availability": "available"
},
{
"suite": "semantic",
"service": "whiteboard",
"command": "+query",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格投影要求 success=true、OpenNodes V1、显式 pages 数组、每页稳定 id 与显式 nodes 数组,并校验跨页节点身份及服务端完整性摘要。",
"availability": "available"
},
{
"suite": "semantic",
"service": "whiteboard",
"command": "+update",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "首次远端调用前完成 OpenNodes V1 校验与用户确认;写后要求 success=true、同一目标、非空终态回执、createdNodeIds/idMap 精确映射,再按真实节点身份独立 query 读回请求关键字段。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
+1
View File
@@ -10,6 +10,7 @@ require (
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/creack/pty v1.1.24
github.com/fatih/color v1.18.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.0
@@ -372,7 +372,7 @@ func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t
{path: "chat +conversation-category-list", candidates: []string{"chat +category-list", "chat +category-list-conversations"}},
{path: "chat +conversation-group-list", candidates: []string{"chat +category-list-conversations", "chat +conversation-list"}},
{path: "chat +list-my-groups", candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"}},
{path: "oa +list-processes", candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}},
{path: "oa +list-processes", candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"}},
}
for _, test := range tests {
t.Run(test.path, func(t *testing.T) {
+11
View File
@@ -1295,6 +1295,17 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
}
func stopPersonalConsumers(w io.Writer, ipcEndpoint string, subscribeIDs []string) error {
hasTarget := false
for _, id := range subscribeIDs {
if strings.TrimSpace(id) != "" {
hasTarget = true
break
}
}
if !hasTarget {
return nil
}
if _, err := personalStopConsumers(ipcEndpoint, subscribeIDs); err == nil {
return nil
} else if !errors.Is(err, busctl.ErrConsumerStopUnsupported) {
+13 -1
View File
@@ -734,7 +734,7 @@ func TestCrossPlatformCoverageRunPersonalEventConsumeManySetupAndCleanupEdges(t
})
}
func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
func TestCrossPlatformCoverageStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
oldStop := personalStopConsumers
oldQuery := personalQueryStatus
oldFind := personalFindProcess
@@ -746,6 +746,18 @@ func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
personalSignalProcess = oldSignal
}()
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
t.Fatal("targeted stop called without a subscribe_id")
return transport.ConsumerStopResp{}, nil
}
personalQueryStatus = func(string) (*transport.StatusResp, error) {
t.Fatal("legacy status queried without a subscribe_id")
return nil, nil
}
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"", " "}); err != nil {
t.Fatalf("empty target stop = %v", err)
}
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
return transport.ConsumerStopResp{Stopped: []string{"sub-a"}}, nil
}
@@ -0,0 +1,175 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageSheetWhiteboardMarkdownRoutes(t *testing.T) {
root := NewRootCommand()
tools := deliverySchemaAllToolsForHelpFlagTest(t, root)
assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t, root, tools)
assertMarkdownDriveRoutesStayCrossProduct(t, root, tools)
assertWhiteboardPublicShortcutsStayAvailableInSchema(t, root, tools)
}
func assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
t.Helper()
registered := 0
for _, item := range shortcut.All() {
if item.Service == "markdown" {
registered++
}
}
if registered != 0 {
t.Fatalf("registered Markdown Shortcuts=%d, want 0: existing composite leaves own these workflows", registered)
}
type route struct {
canonical string
confirmation string
flags []string
}
routes := map[string]route{
"create": {
canonical: "markdown.create", confirmation: "not_required",
flags: []string{"content", "file", "folder", "name", "space-id", "workspace"},
},
"fetch": {
canonical: "markdown.fetch", confirmation: "not_required",
flags: []string{"node", "output", "space-id", "workspace"},
},
"overwrite": {
canonical: "markdown.overwrite", confirmation: "user_required",
flags: []string{"content", "dry-run", "file", "name", "node", "space-id", "workspace"},
},
"patch": {
canonical: "markdown.patch", confirmation: "user_required",
flags: []string{"content", "dry-run", "node", "pattern", "regex", "space-id", "workspace"},
},
"diff": {
canonical: "markdown.diff", confirmation: "not_required",
flags: []string{"context", "file", "node", "version", "version2"},
},
}
group := mustFindCommand(t, root, "markdown")
children := map[string]bool{}
for _, child := range group.Commands() {
children[child.Name()] = true
}
if len(children) != len(routes) {
t.Fatalf("Markdown ordinary leaves=%v, want exactly five routed workflows", children)
}
for name, want := range routes {
leaf := mustFindCommand(t, root, "markdown", name)
if leaf.Hidden || !leaf.Runnable() {
t.Errorf("markdown %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable())
}
if !children[name] {
t.Errorf("markdown %s is not mounted on the ordinary product group", name)
}
for _, flag := range want.flags {
if leaf.Flags().Lookup(flag) == nil {
t.Errorf("markdown %s is missing routed flag --%s", name, flag)
}
}
if shortcut.InPublicCatalog("markdown", "+"+name) {
t.Errorf("markdown +%s unexpectedly entered the public Shortcut catalog", name)
}
meta, ok := cli.ResolveMeta("markdown " + name)
if !ok {
t.Errorf("markdown %s missing from assembled Schema", name)
continue
}
if meta.Identity.Canonical != want.canonical || meta.Identity.CLIPath != "markdown "+name {
t.Errorf("markdown %s identity=%#v, want canonical=%q cli_path=%q", name, meta.Identity, want.canonical, "markdown "+name)
}
if meta.Safety.Confirmation != want.confirmation {
t.Errorf("markdown %s confirmation=%q, want %q", name, meta.Safety.Confirmation, want.confirmation)
}
tool := tools[want.canonical]
if tool == nil {
t.Errorf("markdown %s missing from full delivery Schema", name)
continue
}
if got := schemaContractString(tool["availability"]); got != "available" {
t.Errorf("markdown %s availability=%q, want available", name, got)
}
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
t.Errorf("markdown %s interface_mode=%q, want composite", name, got)
}
if got := schemaContractString(tool["interface_reason"]); got == "" {
t.Errorf("markdown %s is missing the reviewed composite routing reason", name)
}
}
}
func assertMarkdownDriveRoutesStayCrossProduct(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
t.Helper()
driveShortcuts := map[string]string{
"+copy": "drive.shortcut_copy",
"+delete": "drive.shortcut_delete",
"+find-file": "drive.shortcut_find_file",
"+list": "drive.shortcut_list",
"+move": "drive.shortcut_move",
"+publish-get": "drive.shortcut_publish_get",
"+recycle-restore": "drive.shortcut_recycle_restore",
"+rename": "drive.shortcut_rename",
"+version-download": "drive.shortcut_version_download",
"+version-get": "drive.shortcut_version_get",
"+version-history": "drive.shortcut_version_history",
"+version-revert": "drive.shortcut_version_revert",
}
for name, canonical := range driveShortcuts {
leaf := mustFindCommand(t, root, "drive", name)
if leaf.Hidden || !leaf.Runnable() {
t.Errorf("drive %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable())
}
if !shortcut.InPublicCatalog("drive", name) {
t.Errorf("drive %s is not in the public Shortcut catalog", name)
}
assertMarkdownCrossProductRoute(t, tools, "drive "+name, canonical)
}
ordinaryRoutes := map[string]string{
"drive permission list": "drive.list_permission",
"drive pull": "drive.folder_pull",
"drive push": "drive.folder_push",
"drive status": "drive.folder_status",
"drive sync": "drive.folder_sync",
"wiki node list": "wiki.list_nodes",
}
for cliPath, canonical := range ordinaryRoutes {
assertMarkdownCrossProductRoute(t, tools, cliPath, canonical)
}
}
func assertMarkdownCrossProductRoute(t *testing.T, tools map[string]map[string]any, cliPath, canonical string) {
t.Helper()
meta, ok := cli.ResolveMeta(cliPath)
if !ok {
t.Errorf("cross-product route %q is missing from assembled Schema", cliPath)
return
}
if meta.Identity.Canonical != canonical || meta.Identity.CLIPath != cliPath {
t.Errorf("cross-product route %q identity=%#v, want canonical=%q", cliPath, meta.Identity, canonical)
}
tool := tools[canonical]
if tool == nil {
t.Errorf("cross-product route %q is missing from full delivery Schema", cliPath)
return
}
if got := schemaContractString(tool["availability"]); got != "available" {
t.Errorf("cross-product route %q availability=%q, want available", cliPath, got)
}
}
+35
View File
@@ -0,0 +1,35 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func TestOAFinalSchemaAvailabilityMatchesReviewedExecution(t *testing.T) {
snapshot := fullSchemaSnapshotForTest(t)
for _, canonical := range []string{
"oa.shortcut_approve_by",
"oa.shortcut_done_approvals",
"oa.shortcut_list_cc",
"oa.shortcut_list_executed",
"oa.shortcut_list_forms",
"oa.shortcut_list_pending",
"oa.shortcut_list_submitted",
"oa.shortcut_my_initiated",
"oa.shortcut_pending",
"oa.shortcut_search_forms",
} {
tool, ok := snapshot.Tools[canonical]
if !ok {
t.Errorf("final Schema lacks OA tool %s", canonical)
continue
}
if got := tool["availability"]; got != contract.InterfaceAvailable {
t.Errorf("%s final availability=%v, want %q", canonical, got, contract.InterfaceAvailable)
}
}
}
+28
View File
@@ -68,6 +68,34 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
return `{"success":true,"result":[]}`
case "list_suggested_event_times":
return `{"success":true,"result":{"recommendEventTimes":[]}}`
case "list_by_keyword_and_time_range":
return `{"success":true,"result":{"itemList":[{"taskUuid":"u1","startTime":1}]}}`
case "get_minutes_basic_info":
return `{"success":true,"result":{"taskUuid":"u1","title":"Fixture Minutes"}}`
case "get_minutes_transcription":
return `{"success":true,"result":{"paragraphList":[],"hasNext":false}}`
case "create_personal_todo":
return `{"success":true,"result":{"taskId":"task-1"}}`
case "get_todo_detail":
return `{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"Fixture Todo","isDone":false}}}`
case "get_user_todos_in_current_org":
return `{"success":true,"result":{"todoCards":[],"hasMore":false}}`
case "add_todo_reminder":
return `{"success":true}`
case "copy_document":
return `{"success":true,"nodeId":"copy-1"}`
case "move_document", "add_member", "update_member", "remove_member":
return `{"success":true}`
case "get_document_info":
if len(c.calls) > 1 {
switch c.calls[len(c.calls)-2].tool {
case "copy_document":
return `{"success":true,"nodeId":"copy-1","workspaceId":"workspace-1","folderId":"folder-1"}`
case "move_document":
return `{"success":true,"nodeId":"node-1","workspaceId":"drive-1","folderId":"folder-1"}`
}
}
return `{"success":true,"nodeId":"node-1","workspaceId":"source-1","folderId":"source-folder"}`
case "create_calendar_event":
return `{"success":true,"result":{"eventId":"event-1"}}`
case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant":
@@ -226,12 +226,72 @@ var paramAliasCompleteCommands = map[string][]string{
"mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
"mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"},
"mail user search": {"mail", "user", "search", "--keyword", "fixture"},
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "1"},
"oa +search-forms": {"oa", "+search-forms", "--query", "fixture"},
"oa approval search-forms": {"oa", "approval", "search-forms", "--query", "fixture"},
"report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"},
}
// paramAliasCandidateCompleteCommands contains complete invocations for the
// reviewed Minutes/TODO/Wiki joint draft. Keeping candidate-only commands in a
// separate map lets this test file land before the draft replaces the formal
// param_concepts.json: inactive candidate templates are ignored, while every
// command becomes mandatory as soon as one of its reviewed aliases is active.
var paramAliasCandidateCompleteCommands = map[string][]string{
"minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"},
"minutes +latest": {"minutes", "+latest", "--keyword", "fixture"},
"minutes +list-all": {"minutes", "+list-all", "--limit", "7"},
"minutes +record-pause": {"minutes", "+record-pause", "--id", "u1", "--yes"},
"minutes +replace-batch": {"minutes", "+replace-batch", "--id", "u1", "--pair", "old=>new", "--yes"},
"minutes +search": {"minutes", "+search", "--query", "fixture", "--cursor", "cursor-1"},
"minutes +share": {"minutes", "+share", "--ids", "u1,u2", "--member-uids", "user-1,user-2", "--permission", "view", "--yes"},
"minutes +speaker-replace": {"minutes", "+speaker-replace", "--id", "u1", "--from", "old", "--to", "new", "--target-uid", "user-1", "--yes"},
"minutes +summary": {"minutes", "+summary", "--id", "u1", "--content", "fixture", "--yes"},
"minutes +transcript": {"minutes", "+transcript", "--keyword", "fixture"},
"minutes +upload-and-analyze": {"minutes", "+upload-and-analyze", "--resume-id", "u1", "--yes"},
"minutes audio-memo list": {"minutes", "audio-memo", "list", "--max", "7"},
"minutes get batch": {"minutes", "get", "batch", "--ids", "u1,u2"},
"minutes hot-word add": {"minutes", "hot-word", "add", "--words", "DWS,Minutes"},
"minutes list all": {"minutes", "list", "all", "--end", "2026-03-10T23:59:59+08:00"},
"minutes list mine": {"minutes", "list", "mine", "--start", "2026-03-10T00:00:00+08:00"},
"minutes replace-text": {"minutes", "replace-text", "--id", "u1", "--search", "old", "--replace", "new"},
"minutes tag query": {"minutes", "tag", "query", "--tag-id", "tag-1"},
"minutes update title": {"minutes", "update", "title", "--id", "u1", "--title", "Fixture Minutes"},
"minutes upload complete": {"minutes", "upload", "complete", "--session-id", "session-1"},
"todo +assign": {"todo", "+assign", "--task", "Fixture Todo", "--to", "Fixture User", "--yes"},
"todo +assign-multi": {"todo", "+assign-multi", "--task", "Fixture Todo", "--to", "Fixture User,User Two", "--yes"},
"todo +comment": {"todo", "+comment", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
"todo +complete": {"todo", "+complete", "--task-id", "task-1", "--yes"},
"todo +create": {"todo", "+create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
"todo +due-today": {"todo", "+due-today", "--role-types", "executor"},
"todo +get-my-tasks": {"todo", "+get-my-tasks", "--role-types", "executor", "--priority", "40", "--page", "2", "--size", "7"},
"todo +get-related-tasks": {"todo", "+get-related-tasks", "--role-types", "creator,executor", "--status", "false"},
"todo +list-comment": {"todo", "+list-comment", "--task-id", "task-1", "--page", "2"},
"todo +remind": {"todo", "+remind", "--task", "Fixture Todo", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
"todo +reminder": {"todo", "+reminder", "--task-id", "task-1", "--base-time", "customTime", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
"todo +reopen": {"todo", "+reopen", "--task-id", "task-1", "--yes"},
"todo +search": {"todo", "+search", "--query", "fixture", "--status", "false"},
"todo +todo-done": {"todo", "+todo-done", "--task", "Fixture Todo", "--yes"},
"todo +update": {"todo", "+update", "--task-id", "task-1", "--title", "Fixture Updated Todo", "--yes"},
"todo comment add": {"todo", "comment", "add", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
"todo comment list": {"todo", "comment", "list", "--task-id", "task-1", "--page", "2", "--size", "7"},
"todo task add-executor": {"todo", "task", "add-executor", "--task-id", "task-1", "--executors", "user-1,user-2", "--yes"},
"todo task add-participant": {"todo", "task", "add-participant", "--task-id", "task-1", "--participants", "user-1,user-2", "--yes"},
"todo task add-reminder": {"todo", "task", "add-reminder", "--task-id", "task-1", "--base-time", "customTime", "--reminder-time-stamp", "2026-03-10T18:00:00+08:00", "--yes"},
"todo task create": {"todo", "task", "create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
"todo task create-sub": {"todo", "task", "create-sub", "--parent-id", "task-parent", "--title", "Fixture Sub Todo", "--executors", "user-1", "--yes"},
"todo task done": {"todo", "task", "done", "--task-id", "task-1", "--status", "true", "--yes"},
"todo task get": {"todo", "task", "get", "--task-id", "task-1"},
"todo task list": {"todo", "task", "list", "--role-types", "executor", "--page", "2", "--size", "7"},
"todo task update": {"todo", "task", "update", "--task-id", "task-1", "--done", "true", "--yes"},
"wiki +member-add": {"wiki", "+member-add", "--workspace", "workspace-1", "--user", "user-1", "--role", "READER", "--yes"},
"wiki +member-remove": {"wiki", "+member-remove", "--workspace", "workspace-1", "--user", "user-1", "--yes"},
"wiki +member-update": {"wiki", "+member-update", "--workspace", "workspace-1", "--user", "user-1", "--role", "EDITOR", "--yes"},
"wiki +move": {"wiki", "+move", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
"wiki +move-to-drive": {"wiki", "+move-to-drive", "--node", "node-1", "--folder", "folder-1", "--yes"},
"wiki +node-copy": {"wiki", "+node-copy", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
"wiki +node-delete": {"wiki", "+node-delete", "--workspace", "workspace-1", "--node", "node-1", "--yes"},
}
// A command can expose more than one mutually exclusive canonical route. In
// that case the shared command template above cannot contain every canonical
// flag at once, so select a fixture-specific complete invocation here.
@@ -531,6 +591,20 @@ var paramAliasNewConfirmationCases = []struct {
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
}
// Candidate confirmation cases become active with the joint draft. One write
// workflow per product plus TODO's reminder workflow proves semantic aliasing
// cannot move execution across the shared --yes barrier.
var paramAliasCandidateConfirmationCases = []struct {
command string
emitted string
canonical string
}{
{command: "minutes +record-pause", emitted: "uuid", canonical: "id"},
{command: "todo +create", emitted: "deadline", canonical: "due"},
{command: "todo +reminder", emitted: "reminder-time-stamp", canonical: "at"},
{command: "wiki +node-copy", emitted: "node-id", canonical: "node"},
}
// paramAliasRepresentativePayloadCases keeps final transport coverage across
// old concept aliases, command overrides, native compatibility flags, read and
// write commands, and different products. Every reviewed alias is still
@@ -600,6 +674,30 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
// Candidate representatives exercise the final transport boundary for each
// Minutes/TODO/Wiki alias family. They are required only when the exact fixture
// exists in the loaded reviewed table, so the tests are mergeable before the
// joint draft is promoted to internal/cli/param_concepts.json.
var paramAliasCandidateRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("minutes +latest", "query"): true,
paramAliasPayloadCaseKey("minutes +transcript", "query"): true,
paramAliasPayloadCaseKey("minutes get batch", "uuids"): true,
paramAliasPayloadCaseKey("minutes update title", "task-uuid"): true,
paramAliasPayloadCaseKey("minutes upload complete", "upload-id"): true,
paramAliasPayloadCaseKey("todo +create", "deadline"): true,
paramAliasPayloadCaseKey("todo +get-my-tasks", "current-page"): true,
paramAliasPayloadCaseKey("todo +reminder", "reminder-time-stamp"): true,
paramAliasPayloadCaseKey("todo comment add", "text"): true,
paramAliasPayloadCaseKey("todo task add-executor", "executor-ids"): true,
paramAliasPayloadCaseKey("todo task get", "todo-id"): true,
paramAliasPayloadCaseKey("todo task update", "status"): true,
paramAliasPayloadCaseKey("wiki +member-add", "user-id"): true,
paramAliasPayloadCaseKey("wiki +member-remove", "uid"): true,
paramAliasPayloadCaseKey("wiki +member-update", "user-id"): true,
paramAliasPayloadCaseKey("wiki +move-to-drive", "node-id"): true,
paramAliasPayloadCaseKey("wiki +node-copy", "node-id"): true,
}
// paramAliasCalendarPayloadCases keeps the full reviewed Calendar expansion
// separate from the long-lived app-c race process. Each case still executes
// both canonical and alias argv through the real PreParse/Cobra path and
@@ -709,6 +807,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
}
activeCommands := make(map[string]bool)
activeFixtureCases := make(map[string]bool)
activeCases := 0
executedRepresentatives := make(map[string]bool)
for _, fixture := range concepts.Fixture {
@@ -717,6 +816,8 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
}
activeCommands[fixture.Command] = true
activeCases++
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
activeFixtureCases[caseKey] = true
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
if !ok {
t.Errorf("reviewed active fixture %q/%q has no complete-command E2E template", fixture.Command, fixture.Emitted)
@@ -729,8 +830,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
continue
}
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
if !paramAliasRepresentativePayloadCases[caseKey] {
if !paramAliasRepresentativePayloadCases[caseKey] && !paramAliasCandidateRepresentativePayloadCases[caseKey] {
continue
}
executedRepresentatives[caseKey] = true
@@ -742,26 +842,43 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
if activeCases == 0 {
t.Fatal("reviewed fixture contains no active alias cases")
}
templateCommands := make(map[string]bool, len(paramAliasCompleteCommands)+len(paramAliasCandidateCompleteCommands))
for command := range paramAliasCompleteCommands {
if !activeCommands[command] {
t.Errorf("complete-command E2E template %q has no active reviewed fixture", command)
}
templateCommands[command] = true
}
for command := range paramAliasCandidateCompleteCommands {
if activeCommands[command] {
templateCommands[command] = true
}
}
for command := range activeCommands {
if _, ok := paramAliasCompleteCommands[command]; !ok {
if !templateCommands[command] {
t.Errorf("active reviewed command %q has no complete-command E2E template", command)
}
}
if len(activeCommands) != len(paramAliasCompleteCommands) {
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(paramAliasCompleteCommands), len(activeCommands), activeCases)
if len(activeCommands) != len(templateCommands) {
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(templateCommands), len(activeCommands), activeCases)
}
for caseKey := range paramAliasRepresentativePayloadCases {
if !executedRepresentatives[caseKey] {
t.Errorf("representative final-payload case %q has no active reviewed fixture", caseKey)
}
}
if len(executedRepresentatives) != len(paramAliasRepresentativePayloadCases) {
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), len(paramAliasRepresentativePayloadCases))
activeRepresentatives := len(paramAliasRepresentativePayloadCases)
for caseKey := range paramAliasCandidateRepresentativePayloadCases {
if !activeFixtureCases[caseKey] {
continue
}
activeRepresentatives++
if !executedRepresentatives[caseKey] {
t.Errorf("candidate representative final-payload case %q was not executed", caseKey)
}
}
if len(executedRepresentatives) != activeRepresentatives {
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), activeRepresentatives)
}
}
@@ -1091,7 +1208,20 @@ func TestCrossPlatformCoverageNewAITableDeleteDisableAliasesPreserveConfirmation
}
func TestCrossPlatformCoverageNewParamAliasesCannotBypassConfirmation(t *testing.T) {
for _, test := range paramAliasNewConfirmationCases {
tests := append([]struct {
command string
emitted string
canonical string
}{}, paramAliasNewConfirmationCases...)
for _, candidate := range paramAliasCandidateConfirmationCases {
entry, exists := cli.LookupParamAlias(candidate.command)
target, active := entry.ResolveAlias(candidate.emitted)
if exists && active && target == candidate.canonical {
tests = append(tests, candidate)
}
}
for _, test := range tests {
test := test
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
@@ -1173,6 +1303,10 @@ func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
return variant, true
}
}
if ok {
return complete, true
}
complete, ok = paramAliasCandidateCompleteCommands[command]
return complete, ok
}
+3
View File
@@ -72,6 +72,9 @@ func missingChatCatalogCoveragePaths() []string {
"chat clear-messages",
"chat clear-red-point",
"chat data-auth cross-org",
"chat emotion favorite",
"chat emotion list",
"chat emotion send",
"chat group audit-join-validation",
"chat group list-all",
"chat group list-join-validations",
@@ -136,6 +136,53 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t
}
}
// TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema 验证合并后的
// upload 命令以 composite 接口模式交付:它内部串联 init/commit 两个 RPC 与本地 HTTP PUT,
// 无法绑定单一 interface_ref,因此不进入上面按 mcp 模式断言的表驱动用例。
func TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema(t *testing.T) {
snapshot := fullSchemaSnapshotForTest(t)
tool := snapshot.Tools["oa.attachment_upload"]
if tool == nil {
t.Fatal("oa.attachment_upload is missing from final Schema")
}
if got := schemaContractString(tool["primary_cli_path"]); got != "oa approval attachment upload" {
t.Fatalf("primary_cli_path = %q, want oa approval attachment upload", got)
}
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
t.Fatalf("interface_mode = %q, want composite", got)
}
if got := schemaContractString(tool["availability"]); got != "available" {
t.Fatalf("availability = %q, want available", got)
}
if got := schemaContractString(tool["interface_reason"]); got == "" {
t.Fatal("composite upload command must document an interface reason")
}
if got := schemaContractString(tool["effect"]); got != "write" {
t.Fatalf("effect = %q, want write", got)
}
if got := schemaContractString(tool["risk"]); got != "low" {
t.Fatalf("risk = %q, want low", got)
}
if got := schemaContractString(tool["confirmation"]); got != "not_required" {
t.Fatalf("confirmation = %q, want not_required", got)
}
parameters := schemaContractMap(tool["parameters"])
for _, flag := range []string{"file", "file-name", "md5"} {
if parameters[flag] == nil {
t.Fatalf("upload --%s is missing from final Schema", flag)
}
}
if required, _ := parameters["file"]["required"].(bool); !required {
t.Fatalf("upload --file required = %#v, want true", parameters["file"]["required"])
}
result := schemaContractMap(tool["result"])
dataSchema := schemaContractMap(result["data_schema"])
properties := schemaContractMap(dataSchema["properties"])
if properties["fileId"] == nil {
t.Fatal("upload Result data_schema is missing fileId")
}
}
func oaAttachmentResultContract(t *testing.T, tool map[string]any, resultType string, fields map[string]string, sensitivePaths []string) map[string]any {
t.Helper()
result, ok := tool["result"].(map[string]any)
+129 -5
View File
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 435
publicShortcutCount = 425
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 438
// including reviewed hidden compatibility and unavailable contracts.
schemaPublishedShortcutCount = 482
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 435
publiclyDeliveredShortcutCount = 425
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 217; got != want {
if got, want := int(product["count"].(float64)), 220; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
@@ -140,6 +140,57 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
assertChatCatalogCompleteLeafContracts(t)
}
func TestChatPersonalEmotionSchemaDeclaresUnpinnedIMAdapter(t *testing.T) {
for _, tc := range []struct {
cliPath string
params map[string]string
}{
{
cliPath: "chat emotion list",
},
{
cliPath: "chat emotion send",
params: map[string]string{
"media-id": "mediaId",
"emotion-id": "emotionId",
"group": "openConversationId",
"open-dingtalk-id": "receiverOpenDingTalkId",
"idempotency-key": "uuid",
},
},
{
cliPath: "chat emotion favorite",
params: map[string]string{
"media-id": "mediaId",
"name": "name",
"source-conversation-id": "sourceConversationId",
"source-message-id": "sourceMessageId",
},
},
} {
t.Run(tc.cliPath, func(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", tc.cliPath)
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
t.Fatalf("%s interface_mode = %q, want composite", tc.cliPath, got)
}
reason := schemaContractString(leaf["interface_reason"])
if !strings.Contains(reason, "Reviewed unpinned remote adapter") {
t.Fatalf("%s interface_reason = %q", tc.cliPath, reason)
}
parameters := schemaContractMap(leaf["parameters"])
for name, want := range tc.params {
parameter := parameters[name]
if parameter == nil {
t.Fatalf("%s missing --%s parameter: %#v", tc.cliPath, name, parameters)
}
if got := schemaContractString(parameter["property"]); got != want {
t.Fatalf("%s --%s property = %q, want %q", tc.cliPath, name, got, want)
}
}
})
}
}
func TestCrossPlatformCoverageAITableTableBootstrapPublishesResultContract(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", "aitable +table-bootstrap")
result, _ := leaf["result"].(map[string]any)
@@ -220,6 +271,60 @@ func TestAllShortcutsWikiSchemaExamplesIncludeRequiredParameters(t *testing.T) {
}
}
func TestAllShortcutsAITableDatasourceExamplesSourceConfigHasRequiredMembers(t *testing.T) {
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
requiredSourceConfigMembers := []string{"processCode", "name", "iconUrl", "url"}
checked := 0
for _, declared := range shortcut.All() {
if declared.Service != "aitable" || declared.UserDefined || !shortcut.InPublicCatalog(declared.Service, declared.Command) {
continue
}
if !strings.HasPrefix(declared.Command, "+datasource-") {
continue
}
if declared.Command != "+datasource-create" && declared.Command != "+datasource-update" && declared.Command != "+datasource-get-fields" {
continue
}
checked++
canonical := shortcutSchemaCanonical(declared)
tool := tools[canonical]
if tool == nil {
t.Fatalf("delivery schema --all is missing %s", canonical)
}
examples := schemaContractStringSlice(tool["examples"])
if len(examples) == 0 {
t.Fatalf("%s has no delivered examples", canonical)
}
for _, example := range examples {
if !strings.Contains(example, "--source-config") {
continue
}
argv, err := cli.ParseAgentExampleArgv(example)
if err != nil {
t.Fatalf("%s example %q is not valid argv: %v", canonical, example, err)
}
sourceConfig := schemaExampleFlagValue(argv, "source-config")
if sourceConfig == "" {
t.Errorf("%s example %q contains --source-config but has no value", canonical, example)
continue
}
var cfg map[string]any
if err := json.Unmarshal([]byte(sourceConfig), &cfg); err != nil {
t.Errorf("%s example %q has invalid source-config JSON: %v", canonical, example, err)
continue
}
for _, member := range requiredSourceConfigMembers {
if _, ok := cfg[member]; !ok {
t.Errorf("%s example %q source-config is missing required member %q", canonical, example, member)
}
}
}
}
if checked != 3 {
t.Fatalf("checked aitable datasource source-config examples = %d, want 3", checked)
}
}
func schemaExampleHasLongFlag(argv []string, names ...string) bool {
for _, argument := range argv {
for _, name := range names {
@@ -231,6 +336,25 @@ func schemaExampleHasLongFlag(argv []string, names ...string) bool {
return false
}
func schemaExampleFlagValue(argv []string, name string) string {
prefix := "--" + name + "="
for _, argument := range argv {
if argument == "--"+name {
continue
}
if strings.HasPrefix(argument, prefix) {
return strings.TrimPrefix(argument, prefix)
}
}
// Value may be in the next argv entry: `--flag value` form.
for i := 0; i < len(argv)-1; i++ {
if argv[i] == "--"+name {
return argv[i+1]
}
}
return ""
}
func assertSchemaSummarySafety(
t testing.TB,
summaries map[string]map[string]any,
@@ -0,0 +1,42 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"testing"
"github.com/spf13/cobra"
)
func assertWhiteboardPublicShortcutsStayAvailableInSchema(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
t.Helper()
for canonical, command := range map[string]string{
"whiteboard.shortcut_query": "+query",
"whiteboard.shortcut_update": "+update",
} {
leaf, _, err := root.Find([]string{"whiteboard", command})
if err != nil || leaf == nil || leaf.Name() != command {
t.Errorf("find whiteboard %s: leaf=%v err=%v", command, leaf, err)
} else if leaf.Hidden || !leaf.Runnable() {
t.Errorf("whiteboard %s hidden/runnable=%v/%v, want false/true", command, leaf.Hidden, leaf.Runnable())
}
tool := tools[canonical]
if tool == nil {
t.Errorf("public %s missing from delivery Schema surface", canonical)
continue
}
if got := schemaContractString(tool["availability"]); got != "available" {
t.Errorf("%s availability=%q, want available", canonical, got)
}
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
t.Errorf("%s interface_mode=%q, want composite", canonical, got)
}
if got := schemaContractString(tool["interface_reason"]); got == "" {
t.Errorf("%s missing composite adapter reason", canonical)
}
if tool["interface_ref"] != nil {
t.Errorf("%s composite interface_ref=%#v, want nil", canonical, tool["interface_ref"])
}
}
}
+2
View File
@@ -192,6 +192,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
updated.CorpID = data.CorpID
updated.UserID = data.UserID
updated.UserName = data.UserName
updated.RepairOrganizationMirror = data.RepairOrganizationMirror
if updated.CorpName == "" {
updated.CorpName = data.CorpName
}
@@ -239,6 +240,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
updated.CorpID = data.CorpID
updated.UserID = data.UserID
updated.UserName = data.UserName
updated.RepairOrganizationMirror = data.RepairOrganizationMirror
if updated.CorpName == "" {
updated.CorpName = data.CorpName
}
+78 -1
View File
@@ -811,7 +811,84 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
if p.logger != nil {
p.logger.Debug("refreshing token (dual-locked)")
}
return oauthRefreshToken(p, ctx, data)
refreshed, rErr := oauthRefreshToken(p, ctx, data)
if rErr == nil || !isRefreshTokenRejected(rErr) {
return refreshed, rErr
}
// A stale identity slot can survive an older organization-only refresh.
// Retry once with the same-corp organization mirror while holding the
// existing dual lock; the fallback marks the publication so the rotated
// credential is written back into the mirror slot it consumed.
logging.AuthDebug(
"auth.refresh.fallback.triggered",
"corp_id", strings.TrimSpace(data.CorpID),
"user_id", strings.TrimSpace(data.UserID),
"error", rErr,
)
fallback, fErr := p.refreshFromOrgSlot(ctx, data)
if fErr != nil {
logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr)
return nil, rErr
}
if p.logger != nil {
p.logger.Warn(i18n.T("当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态"))
}
return fallback, nil
}
// refreshFromOrgSlot retries a rejected refresh with the token mirrored in
// the organization slot. The mirror must match the current corp, be valid,
// and differ from the rejected token. When both slots carry user identities,
// they must agree; legacy mirrors with an empty UserID are backfilled from the
// current identity before refresh.
func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenData) (*TokenData, error) {
if current == nil {
return nil, fmt.Errorf("no current token data")
}
corpID := strings.TrimSpace(current.CorpID)
if corpID == "" {
return nil, fmt.Errorf("current token has no corpId")
}
orgData, err := tokenLoadKeychainForCorpID(corpID)
if err != nil {
return nil, err
}
if orgData == nil {
return nil, ErrTokenDataNotFound
}
if strings.TrimSpace(orgData.CorpID) != corpID {
return nil, fmt.Errorf("organization token mirror for corpId %q contains token for corpId %q; refusing refresh fallback", corpID, orgData.CorpID)
}
if !orgData.IsRefreshTokenValid() {
return nil, fmt.Errorf("organization mirror refresh_token 已过期")
}
if orgData.RefreshToken == current.RefreshToken {
return nil, fmt.Errorf("organization mirror holds the same rejected refresh_token")
}
currentUserID := strings.TrimSpace(current.UserID)
orgUserID := strings.TrimSpace(orgData.UserID)
if currentUserID != "" && orgUserID != "" && orgUserID != currentUserID {
return nil, fmt.Errorf("organization token mirror for corpId %q belongs to userId %q; refusing refresh fallback for userId %q", corpID, orgData.UserID, current.UserID)
}
if orgUserID == "" {
orgData.UserID = current.UserID
orgData.UserName = current.UserName
}
// The refresh below consumes the mirror's refresh_token. Mark the
// publication so persistence writes the rotated credential back into the
// organization slot even under an explicit runtime selector whose plan
// would otherwise skip it (for example a preserved unresolved sibling).
orgData.RepairOrganizationMirror = true
refreshed, err := oauthRefreshToken(p, ctx, orgData)
if err != nil {
return nil, err
}
logging.AuthDebug(
"auth.refresh.fallback.success",
"corp_id", corpID,
"new_at_expires_at", refreshed.ExpiresAt.Format(time.RFC3339),
)
return refreshed, nil
}
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
@@ -0,0 +1,427 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageIsRefreshTokenRejected(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"mcp authCode.notFound", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}, true},
{"mcp other business code", &MCPTokenExchangeError{Code: "other.error", Message: "boom"}, false},
{"wrapped mcp rejection", fmt.Errorf("refresh: %w", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode}), true},
{"http 400 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusBadRequest}, false},
{"http 401 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusUnauthorized}, false},
{"http 403 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusForbidden}, false},
{"http 500 is transient", &HTTPStatusError{StatusCode: http.StatusInternalServerError}, false},
{"http 429 is transient", &HTTPStatusError{StatusCode: http.StatusTooManyRequests}, false},
{"plain error is unknown", errors.New("boom"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isRefreshTokenRejected(tt.err); got != tt.want {
t.Fatalf("isRefreshTokenRejected(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
// orgSlotFallbackFixture wires the injectable seams lockedRefresh depends on
// and records refresh attempts plus organization slot lookups.
type orgSlotFallbackFixture struct {
provider *OAuthProvider
stale *TokenData
orgMirror *TokenData
renewed *TokenData
rejected *MCPTokenExchangeError
refreshErr error
orgRefreshErr error
refreshCalls []string
refreshUserIDs []string
orgLoads int
}
func newOrgSlotFallbackFixture(t *testing.T) *orgSlotFallbackFixture {
t.Helper()
isolateOAuthPersistence(t)
f := &orgSlotFallbackFixture{
provider: &OAuthProvider{configDir: t.TempDir(), logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Output: io.Discard},
stale: &TokenData{
AccessToken: "old-access",
RefreshToken: "stale-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
UserID: "user-1",
},
orgMirror: &TokenData{
AccessToken: "org-access",
RefreshToken: "org-refresh",
ExpiresAt: time.Now().Add(-time.Minute),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
UserID: "user-1",
},
renewed: &TokenData{
AccessToken: "new-access",
RefreshToken: "new-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "corp-1",
UserID: "user-1",
},
rejected: &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"},
}
f.refreshErr = f.rejected
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(configDir, _ string) (*TokenData, error) { return oauthLoadToken(configDir) })
testseam.Swap(t, &oauthLoadToken, func(string) (*TokenData, error) { return f.stale, nil })
testseam.Swap(t, &oauthRefreshToken, func(_ *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
f.refreshCalls = append(f.refreshCalls, data.RefreshToken)
f.refreshUserIDs = append(f.refreshUserIDs, data.UserID)
switch data.RefreshToken {
case "stale-refresh":
return nil, f.refreshErr
case "org-refresh":
return f.renewed, f.orgRefreshErr
}
return nil, fmt.Errorf("unexpected refresh token %q", data.RefreshToken)
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(corpID string) (*TokenData, error) {
f.orgLoads++
if corpID != "corp-1" {
return nil, ErrTokenDataNotFound
}
return f.orgMirror, nil
})
return f
}
func TestCrossPlatformCoverageLockedRefreshFallsBackToOrgSlot(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
got, err := f.provider.lockedRefresh(context.Background())
if err != nil || got != f.renewed {
t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err)
}
if len(f.refreshCalls) != 2 || f.refreshCalls[0] != "stale-refresh" || f.refreshCalls[1] != "org-refresh" {
t.Fatalf("refresh attempts = %v, want [stale-refresh org-refresh]", f.refreshCalls)
}
if f.orgLoads != 1 {
t.Fatalf("organization slot loads = %d, want 1", f.orgLoads)
}
}
func TestCrossPlatformCoverageRepairMarkerForcesOrganizationSlotWrite(t *testing.T) {
cfg := &ProfilesConfig{
Version: profilesVersion,
Profiles: []Profile{
{Name: "legacy", CorpID: "corp-1", UserID: ""},
{Name: "user-1", CorpID: "corp-1", UserID: "user-1"},
},
}
selector := profileSelector("corp-1", "user-1")
without := &TokenData{CorpID: "corp-1", UserID: "user-1"}
if plan := planTokenPersistenceWrites(cfg, without, selector); plan.WriteOrganization {
t.Fatalf("explicit selector preserved unresolved org slot: WriteOrganization = true, want false")
}
with := &TokenData{CorpID: "corp-1", UserID: "user-1", RepairOrganizationMirror: true}
if plan := planTokenPersistenceWrites(cfg, with, selector); !plan.WriteOrganization {
t.Fatalf("repair marker did not force the organization slot write")
}
}
func TestCrossPlatformCoverageLockedRefreshFallbackRepairsPersistedSlots(t *testing.T) {
isolateOAuthPersistence(t)
t.Setenv("DWS_CLIENT_ID", "")
t.Setenv("DWS_CLIENT_SECRET", "")
// Fake MCP refresh endpoint: the first call rejects the stale identity
// refresh_token with the reviewed business code; the second call (the
// organization mirror) succeeds and returns a rotated credential.
var refreshCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if refreshCalls.Add(1) == 1 {
fmt.Fprint(w, `{"errorCode":"invalidParameter.authCode.notFound","errorMsg":"authCode not found"}`)
return
}
fmt.Fprint(w, `{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp-1","userId":"user-1","userName":"User One"}`)
}))
defer srv.Close()
configDir := setupMCPConfigDir(t, srv.URL)
resetAppConfigCache()
// Seed the pre-fallback state: an identity slot whose refresh_token the
// server rejects, plus a legacy organization mirror (no userId) with a
// still-valid refresh_token and a preserved unresolved sibling profile so
// an explicit --profile refresh would normally skip the org slot.
cfg := &ProfilesConfig{
Version: profilesVersion,
Profiles: []Profile{
{Name: "corp-1", CorpID: "corp-1", UserID: "", ClientID: "mcp-client"},
{Name: "user-1", CorpID: "corp-1", UserID: "user-1", UserName: "User One", ClientID: "mcp-client"},
},
}
if err := SaveProfiles(configDir, cfg); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
orgMirror := &TokenData{
AccessToken: "org-access",
RefreshToken: "org-refresh",
ExpiresAt: time.Now().Add(-time.Minute),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
Source: "mcp",
ClientID: "mcp-client",
}
if err := SaveTokenDataKeychainForCorpID("corp-1", orgMirror); err != nil {
t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err)
}
staleIdentity := &TokenData{
AccessToken: "stale-access",
RefreshToken: "stale-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
UserID: "user-1",
UserName: "User One",
Source: "mcp",
ClientID: "mcp-client",
}
if err := SaveTokenDataKeychainForIdentity("corp-1", "user-1", staleIdentity); err != nil {
t.Fatalf("SaveTokenDataKeychainForIdentity() error = %v", err)
}
SetRuntimeProfile("corp-1:user-1")
t.Cleanup(func() { SetRuntimeProfile("") })
p := &OAuthProvider{
configDir: configDir,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Output: io.Discard,
httpClient: srv.Client(),
}
got, err := p.lockedRefresh(context.Background())
if err != nil {
t.Fatalf("lockedRefresh() error = %v", err)
}
if got == nil || got.AccessToken != "new-access" || got.RefreshToken != "new-refresh" {
t.Fatalf("lockedRefresh() = %#v, want rotated credential", got)
}
if refreshCalls.Load() != 2 {
t.Fatalf("MCP refresh calls = %d, want primary rejection plus fallback", refreshCalls.Load())
}
// The fallback consumed the mirror's refresh_token: both persisted slots
// must now carry the rotated credential instead of the consumed one.
orgSlot, err := LoadTokenDataKeychainForCorpID("corp-1")
if err != nil {
t.Fatalf("LoadTokenDataKeychainForCorpID() error = %v", err)
}
if orgSlot.RefreshToken != "new-refresh" || orgSlot.UserID != "user-1" {
t.Fatalf("organization slot = %#v, want new-refresh for user-1", orgSlot)
}
identitySlot, err := LoadTokenDataKeychainForIdentity("corp-1", "user-1")
if err != nil {
t.Fatalf("LoadTokenDataKeychainForIdentity() error = %v", err)
}
if identitySlot.RefreshToken != "new-refresh" {
t.Fatalf("identity slot = %#v, want new-refresh", identitySlot)
}
}
func TestCrossPlatformCoverageLockedRefreshOrgSlotFallbackGuardrails(t *testing.T) {
t.Run("transient failure does not fall back", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.refreshErr = &HTTPStatusError{StatusCode: http.StatusInternalServerError}
_, err := f.provider.lockedRefresh(context.Background())
if err == nil || err.Error() != f.refreshErr.Error() {
t.Fatalf("lockedRefresh() error = %v, want transient failure", err)
}
if f.orgLoads != 0 {
t.Fatalf("organization slot loads = %d, want 0 for transient failure", f.orgLoads)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
t.Run("missing org slot preserves rejection", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) {
f.orgLoads++
return nil, ErrTokenDataNotFound
})
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
t.Run("nil org data from keychain preserves rejection", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) {
f.orgLoads++
return nil, nil
})
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
t.Run("org slot refresh failure preserves rejection", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgRefreshErr = fmt.Errorf("org mirror refresh failed")
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 2 {
t.Fatalf("refresh attempts = %v, want primary and fallback attempts", f.refreshCalls)
}
})
t.Run("different user in org slot is rejected", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgMirror.UserID = "user-2"
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, mismatched user must not refresh", f.refreshCalls)
}
})
t.Run("empty org slot user identity is backfilled", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgMirror.UserID = ""
f.orgMirror.UserName = ""
got, err := f.provider.lockedRefresh(context.Background())
if err != nil || got != f.renewed {
t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err)
}
if len(f.refreshCalls) != 2 {
t.Fatalf("refresh attempts = %v, want fallback attempt", f.refreshCalls)
}
if f.refreshUserIDs[1] != "user-1" {
t.Fatalf("fallback refresh UserID = %q, want backfilled current identity user-1", f.refreshUserIDs[1])
}
})
t.Run("same rejected refresh token is skipped", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgMirror.RefreshToken = "stale-refresh"
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, retrying the rejected token must not run", f.refreshCalls)
}
})
t.Run("expired org refresh token is skipped", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgMirror.RefreshExpAt = time.Now().Add(-time.Hour)
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
t.Run("missing corp id skips fallback", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.stale.CorpID = ""
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if f.orgLoads != 0 {
t.Fatalf("organization slot loads = %d, want 0 without corpId", f.orgLoads)
}
})
t.Run("direct mode terminal status does not fall back without business code", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.refreshErr = &HTTPStatusError{StatusCode: http.StatusBadRequest}
_, err := f.provider.lockedRefresh(context.Background())
if err == nil || err.Error() != f.refreshErr.Error() {
t.Fatalf("lockedRefresh() error = %v, want direct terminal status", err)
}
if f.orgLoads != 0 {
t.Fatalf("fallback slot loads = %d, want 0 without reviewed business code", f.orgLoads)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
}
func TestCrossPlatformCoverageRefreshFromOrgSlotBoundaries(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
if _, err := f.provider.refreshFromOrgSlot(context.Background(), nil); err == nil {
t.Fatal("refreshFromOrgSlot(nil) succeeded")
}
f.orgMirror.CorpID = "corp-2"
if _, err := f.provider.refreshFromOrgSlot(context.Background(), f.stale); err == nil {
t.Fatal("refreshFromOrgSlot with mismatched corpId succeeded")
}
if len(f.refreshCalls) != 0 {
t.Fatalf("refresh attempts = %v, corpId mismatch must not refresh", f.refreshCalls)
}
}
+13
View File
@@ -87,3 +87,16 @@ func ClassifyRefreshFailure(err error) RefreshFailureClass {
}
return RefreshFailureUnknown
}
// isRefreshTokenRejected reports whether the server returned a reviewed
// business code that definitively rejects the presented refresh_token.
// Only the reviewed MCP business code enables the organization-slot
// fallback; direct-mode terminal HTTP rejections (400/401/403) carry no
// reviewed business code and deliberately do not trigger the fallback.
func isRefreshTokenRejected(err error) bool {
if err == nil {
return false
}
var exchangeErr *MCPTokenExchangeError
return errors.As(err, &exchangeErr) && exchangeErr != nil && exchangeErr.requiresReauthorization()
}
+16 -1
View File
@@ -107,6 +107,13 @@ type TokenData struct {
// transient marker to reject ambiguous UID-less logins without breaking
// legitimate refreshes of unresolved accounts.
FreshAuthorization bool `json:"-"`
// RepairOrganizationMirror marks a fallback refresh that consumed the
// organization mirror's refresh_token. The regular write plan can skip the
// organization slot under an explicit runtime selector (for example when an
// unresolved sibling profile still owns it), which would strand a
// refresh_token the server has already rotated; the marker forces the
// rotated credential back into that slot.
RepairOrganizationMirror bool `json:"-"`
}
// tokenPersistenceWritePlan is the single source of truth for deciding which
@@ -127,6 +134,7 @@ type tokenPersistenceWritePlan struct {
ExistingIdentity bool
UpgradesLegacyProfile bool
PreserveUnresolvedOrganization bool
RepairOrganizationMirror bool
WriteIdentity bool
WriteOrganization bool
WriteGlobal bool
@@ -167,6 +175,11 @@ func planTokenPersistenceWrites(
plan.PreserveUnresolvedOrganization = plan.UserID != "" &&
unresolvedProfileForCorp(cfg, plan.CorpID) != nil &&
!plan.UpgradesLegacyProfile
// A fallback refresh consumed the organization mirror's refresh_token;
// the rotated credential must go back into that slot even when the
// selector-driven plan would skip it (for example an explicit --profile
// that preserves an unresolved sibling profile's slot).
plan.RepairOrganizationMirror = data.RepairOrganizationMirror
plan.WriteIdentity = plan.UserID != ""
orgCurrentSelector := ""
if cfg != nil {
@@ -177,7 +190,8 @@ func planTokenPersistenceWrites(
// 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.WriteOrganization = plan.RepairOrganizationMirror ||
plan.UserID == "" ||
plan.UpgradesLegacyProfile ||
(!plan.PreserveUnresolvedOrganization &&
(plan.MakeCurrent ||
@@ -387,6 +401,7 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
"persistence_profile", plan.PersistenceSelector,
"write_identity_slot", plan.WriteIdentity,
"write_org_mirror", plan.WriteOrganization,
"repair_org_mirror", plan.RepairOrganizationMirror,
"write_global_mirror", plan.WriteGlobal,
"publish_incoming_global", plan.MakeCurrent,
)
+3 -3
View File
@@ -191,12 +191,12 @@
"from": "oa +list-processes",
"mode": "ambiguous",
"candidates": [
"oa +list-forms",
"oa +my-initiated",
"oa +search-forms",
"oa approval list-submitted",
"oa approval list-initiated"
],
"reviewed": true,
"review_reason": "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf."
"review_reason": "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes."
},
{
"from": "chat +conversation-detail",
@@ -242,9 +242,9 @@ var generatedCommandPathFallbacks = []CommandPathFallback{
{
From: "oa +list-processes",
Mode: "ambiguous",
Candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
Candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"},
Reviewed: true,
ReviewReason: "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf.",
ReviewReason: "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes.",
},
}
+1 -1
View File
@@ -172,7 +172,7 @@ func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) {
"chat +send-file": {"chat +messages-send", "chat message send"},
"chat +send-image": {"chat +messages-send", "chat message send"},
"chat +send-media": {"chat +messages-send", "chat message send"},
"oa +list-processes": {"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
"oa +list-processes": {"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"},
"doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"},
"doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"},
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+10 -1
View File
@@ -301,7 +301,7 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) {
t.Fatalf("doc read --content-format required = %#v, want false", contentFormat["required"])
}
for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id"} {
for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id", "version", "password"} {
if parameters[flagName]["required"] != false {
t.Fatalf("doc read --%s required = %#v, want false", flagName, parameters[flagName]["required"])
}
@@ -315,6 +315,15 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) {
if parameters["max-depth"]["type"] != "integer" {
t.Fatalf("doc read --max-depth type = %#v, want integer", parameters["max-depth"]["type"])
}
if got := parameters["version"]["property"]; got != "historyVersion" {
t.Fatalf("doc read --version property = %#v, want historyVersion", got)
}
if parameters["version"]["type"] != "integer" {
t.Fatalf("doc read --version type = %#v, want integer", parameters["version"]["type"])
}
if got := parameters["password"]["property"]; got != "password" {
t.Fatalf("doc read --password property = %#v, want password", got)
}
}
func TestDeliveryCatalogDocCommentParamDeclsMatchMergeBaseContract(t *testing.T) {
@@ -76,6 +76,7 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
"agoal scorecard detail",
"agoal scorecard entity-detail",
"agoal scorecard update",
"agoal scorecard search-entities",
"agoal strategy detail",
"agoal strategy list",
"agoal strategy update",
@@ -408,6 +408,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"doc.insert_document_block --level": "aggregate convenience input used to build element",
"doc.insert_document_block --content": "aggregate convenience input used to build element",
"doc.list_document_blocks --block-id": "runtime extension sends blockId, which is absent from the pinned list_document_blocks metadata",
"doc.list_permission --limit": "The server rejects the legacy maxResults path for list_permission; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
"doc.reply_comment --mentioned-open-conversation-id": "Runtime extension sends mentionedOpenConversationIds, which is absent from the immutable pinned reply_comment metadata at its declared source revision.",
"doc.style_background_clear --node": "Reviewed unpinned adapter: doc.style_background_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"doc.style_background_set --color": "Reviewed unpinned adapter: doc.style_background_set has no singular pinned interface_ref; --color is a CLI wrapper input and does not publish a direct interface property.",
@@ -476,6 +477,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"drive.list_files --space-id": "drive-branch-only route input on a composite drive/doc command; no singular interface property is advertised",
"drive.list_files --thumbnail": "drive-branch-only option on a composite drive/doc command; no singular interface property is advertised",
"drive.list_files --workspace": "selects the doc.list_nodes branch of the composite drive/doc command",
"drive.list_permission --limit": "The server rejects the legacy maxResults path for list_permission; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
"drive.mark_star --node": "Reviewed unpinned adapter: drive.mark_star has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.publish_get --node": "Reviewed unpinned adapter: drive.publish_get has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.publish_set --node": "Reviewed unpinned adapter: drive.publish_set has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
@@ -664,6 +666,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"todo.list_todo_attachment --task-id": "Reviewed unpinned adapter: --task-id is nested under todoAttachmentListRequest at runtime, while the immutable pinned MCP snapshot has no interface_ref for todo.list_todo_attachment.",
"wiki.create_wikiSpace --icon": "runtime extension sends icon, which is absent from the pinned create_wikiSpace metadata",
"wiki.delete_document --workspace": "local validation/authorization context; not sent to delete_document",
"wiki.list_member --limit": "The server rejects the legacy maxResults path for list_member; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
"wiki.list_wikiSpaces --cursor": "composite route maps to pageToken for wiki.list_wikiSpaces or nextToken for drive.list_spaces",
"wiki.list_wikiSpaces --limit": "composite route maps to pageSize for wiki.list_wikiSpaces or maxResults for drive.list_spaces",
"wiki.list_wikiSpaces --type": "route selector maps to wikiSpaceType or spaceType on different composite branches",
+7 -1
View File
@@ -32,12 +32,18 @@ func ValidateInputSchema(params map[string]any, schema map[string]any) error {
if params == nil {
params = map[string]any{}
}
if err := validateSchemaValue("$", params, schema); err != nil {
if err := ValidateJSONSchemaValue(params, schema); err != nil {
return apperrors.NewValidation(fmt.Sprintf("input schema validation failed: %v", err))
}
return nil
}
// ValidateJSONSchemaValue validates one decoded JSON value against the
// required/type/enum/properties/items subset used by reviewed CLI contracts.
func ValidateJSONSchemaValue(value any, schema map[string]any) error {
return validateSchemaValue("$", value, schema)
}
func validateSchemaValue(path string, value any, schema map[string]any) error {
if len(schema) == 0 {
return nil
+56
View File
@@ -0,0 +1,56 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package commentreaction validates the reviewed DingTalk names accepted by
// comment emoji replies. The names mirror the bundled default emoji catalog;
// arbitrary text and raw Unicode emoji must never be persisted as reactions.
package commentreaction
import (
"strings"
"unicode/utf8"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// Validate rejects values outside DingTalk's reviewed default reaction-name
// catalog. Names are case-sensitive because they are sent verbatim to the
// doc-comment service.
func Validate(value string) error {
name := strings.TrimSpace(value)
if value != name || !utf8.ValidString(name) || !supported(name) {
return apperrors.NewValidation(
"--reaction/--content 必须是受支持的钉钉表情名称(如 憨笑、鼓掌、比心、赞),不要传 Unicode Emoji 或任意文本",
apperrors.WithReason("unsupported_comment_reaction"),
)
}
return nil
}
func supported(name string) bool {
switch name {
case "微笑", "可爱", "憨笑", "色", "发呆", "老板", "傻笑", "流泪", "害羞", "闭嘴",
"睡", "大哭", "尴尬", "感谢", "拒绝", "赞", "鼓掌", "打招呼", "666", "抱拳",
"握手", "OK", "胜利", "向左", "向右", "向上", "向下", "来呀", "一点点", "捏住",
"比心", "送花花", "加油干", "调皮", "大笑", "惊讶", "流汗", "奋斗", "口罩", "生病",
"吐", "难过", "抓狂", "右哼哼", "太阳", "月亮", "强", "弱", "彩带", "蛋糕",
"骷髅", "撇嘴", "鄙视", "嘘", "思考", "亲亲", "无奈", "感冒", "对不起", "再见",
"投降", "哼", "欠扁", "拜托", "可怜", "舒服", "爱意", "财迷", "迷惑", "委屈",
"灵感", "天使", "鬼脸", "凄凉", "郁闷", "坏笑", "算账", "PK", "忍者", "衰",
"炸弹", "笑哭", "嘿嘿", "捂脸哭", "抠鼻", "流鼻血", "呲牙", "吃瓜", "彩虹", "耶",
"发怒", "捂眼睛", "推眼镜", "暗中观察", "脑暴", "冷笑", "热", "开心", "惊喜", "回头",
"白眼", "一团乱麻", "黑眼圈", "裂开", "恭喜", "费解", "收到", "快来", "敲打", "捧脸",
"Get", "客服", "AR", "小蜜蜂", "虎虎生威", "兔飞猛进", "龙头老大", "蛇来运转", "马上来财", "专注",
"忙疯了", "等一等", "一脸苦笑", "王之蔑视", "洪荒之力", "向左看", "向右看", "YYDS", "这边请", "弹射下班",
"退退退", "在吗", "让人头大", "摊手", "抱抱", "举手", "开车", "抱大腿", "跪了", "鞠躬",
"选我", "元气满满", "会议", "猫咪", "二哈", "狗子", "三多", "承让", "撒花", "礼物",
"生日快乐", "爱心", "心碎", "嘴唇", "鲜花", "残花", "干杯", "咖啡", "奶茶", "茶",
"OKR", "KPI", "100分", "对勾", "打叉", "气泡", "加一", "Done", "钉子", "出差",
"高铁", "火箭", "邮件", "文档", "演示", "表格", "废纸篓", "手机", "时间", "静音",
"公文包", "地球", "碳减排", "回收标志", "幼苗", "红包", "锦鲤", "福", "灯笼", "爆竹",
"烟花", "恭喜发财", "月饼", "鸡腿", "休假", "火", "点赞", "平安健康", "定胜":
return true
default:
return false
}
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package commentreaction
import "testing"
func TestCrossPlatformCoverageValidate(t *testing.T) {
for _, value := range []string{"憨笑", "鼓掌", "比心", "赞", "OK", "Done", "平安健康"} {
if err := Validate(value); err != nil {
t.Errorf("supported reaction %q rejected: %v", value, err)
}
}
for _, value := range []string{"", "😄", "👏", "�", "garbled", "乱码", "憨笑\n"} {
if err := Validate(value); err == nil {
t.Errorf("unsupported reaction %q accepted", value)
}
}
}
+18
View File
@@ -151,6 +151,24 @@ func WithOperation(operation string) Option {
}
}
// IsConfirmationRequired reports whether err (or any wrapped cause) is a
// typed framework confirmation-gate failure carrying reason
// confirmation_required. Downstream classifiers must pass such errors through
// verbatim: the "re-run with --yes" semantics can only be carried by the
// machine-readable reason, while message-text classification actively
// misroutes them (a command path containing "permission" would be reported as
// an auth failure, and any other wording degrades to an unclassified error).
func IsConfirmationRequired(err error) bool {
if err == nil {
return false
}
var typed *Error
if stderrors.As(err, &typed) {
return strings.TrimSpace(typed.Reason) == "confirmation_required"
}
return false
}
// WithServerKey records the server identifier associated with the failure.
func WithServerKey(serverKey string) Option {
return func(err *Error) {
+32
View File
@@ -15,6 +15,7 @@ package errors
import (
stderrors "errors"
"fmt"
"os"
"path/filepath"
"strings"
@@ -464,3 +465,34 @@ func TestCrossPlatformCoveragePrintHumanHidesRPCCode_Normal(t *testing.T) {
t.Fatalf("normal mode should not show RPC Code, got %q", got)
}
}
func TestCrossPlatformCoverageIsConfirmationRequired(t *testing.T) {
t.Parallel()
if IsConfirmationRequired(nil) {
t.Fatal("nil error must not report confirmation_required")
}
if IsConfirmationRequired(NewValidation("missing required flag")) {
t.Fatal("plain validation error must not report confirmation_required")
}
plain := stderrors.New("需要用户确认")
if IsConfirmationRequired(plain) {
t.Fatal("message text alone must not report confirmation_required")
}
confirmation := NewValidation(
"blocked",
WithReason("confirmation_required"),
)
if !IsConfirmationRequired(confirmation) {
t.Fatal("typed confirmation error must report confirmation_required")
}
// 包装链(fmt.Errorf %w)必须能穿透到 typed 原因。
wrapped := fmt.Errorf("call tool: %w", confirmation)
if !IsConfirmationRequired(wrapped) {
t.Fatal("wrapped confirmation error must report confirmation_required")
}
otherReason := NewValidation("rate limited", WithReason("rate_limit"))
if IsConfirmationRequired(otherReason) {
t.Fatal("other reasons must not report confirmation_required")
}
}
+32 -1
View File
@@ -31,6 +31,7 @@ func newAgoalCommand() *cobra.Command {
dws agoal scorecard detail 获取计分卡详情
dws agoal scorecard entity-detail 获取计分卡实体详情
dws agoal scorecard update 更新计分卡
dws agoal scorecard search-entities 搜索计分卡指标与关键事项
dws agoal user rules 获取用户规则
dws agoal user objectives 查询用户目标列表
dws agoal report list-statistics 获取周月报数据跟催列表
@@ -359,7 +360,37 @@ scopeType 支持:
scorecardUpdateCmd.Flags().String("content", "", "内容 JSON 数组 (必填)")
scorecardUpdateCmd.Flags().String("request-id", "", "requestId (可选)")
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd)
scorecardSearchContentCmd := &cobra.Command{
Use: "search-entities",
Short: "搜索计分卡指标与关键事项",
Long: `根据关键词模糊搜索计分卡中的指标和关键事项标题,返回匹配的计分卡实体信息(计分卡ID、实体ID、实体类型、标题、所属团队等)。`,
Example: ` dws agoal scorecard search-entities --keyword "业绩"
dws agoal scorecard search-entities --keyword "业绩" --page 1 --page-size 20`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "keyword"); err != nil {
return err
}
toolArgs := map[string]any{
"keyword": mustGetFlag(cmd, "keyword"),
}
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
toolArgs["requestId"] = v
}
if v, _ := cmd.Flags().GetInt("page"); v != 0 {
toolArgs["page"] = v
}
if v, _ := cmd.Flags().GetInt("page-size"); v != 0 {
toolArgs["pageSize"] = v
}
return callMCPTool("search_score_card_entities", toolArgs)
},
}
scorecardSearchContentCmd.Flags().String("keyword", "", "搜索关键词,标题模糊匹配 (必填)")
scorecardSearchContentCmd.Flags().String("request-id", "", "requestId (可选)")
scorecardSearchContentCmd.Flags().Int("page", 0, "页码,默认 1 (可选)")
scorecardSearchContentCmd.Flags().Int("page-size", 0, "每页数量,最大 100 (可选)")
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd, scorecardSearchContentCmd)
// ── user: 用户目标管理 ──────────────────────────────────────
+777 -3
View File
@@ -7,6 +7,8 @@ import (
"io"
"math"
"os"
"reflect"
"sort"
"strconv"
"strings"
"time"
@@ -126,6 +128,83 @@ func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) {
return dsl, nil
}
// executeAitableWorkflowPublish executes a publish exactly once, then requires
// the reviewed valid/flowId envelope before rendering any success output.
// create_workflow is non-idempotent, so transport uncertainty is never retried.
func executeAitableWorkflowPublish(toolName string, args map[string]any) error {
if deps.Caller.DryRun() {
return callMCPToolOnServer("aitable", toolName, args)
}
raw, err := callMCPToolReturnTextOnServer(context.Background(), "aitable", toolName, args)
if err != nil {
return err
}
var envelope map[string]any
if err := json.Unmarshal([]byte(raw), &envelope); err != nil || envelope == nil {
return fmt.Errorf("%s response is not a JSON object", toolName)
}
valid, validFound, flowID, err := strictAitableWorkflowPublishResult(envelope)
if err != nil {
return fmt.Errorf("%s response validation failed: %w", toolName, err)
}
if !validFound {
return fmt.Errorf("%s response is missing valid", toolName)
}
if !valid {
return fmt.Errorf("%s returned valid=false; inspect issues and correct the workflow DSL", toolName)
}
if flowID == "" {
return fmt.Errorf("%s response is missing a non-empty flowId", toolName)
}
return RenderLegacyMCPText(toolName, raw)
}
func strictAitableWorkflowPublishResult(envelope map[string]any) (valid bool, validFound bool, flowID string, err error) {
var visit func(map[string]any) error
visit = func(object map[string]any) error {
if raw, exists := object["valid"]; exists {
value, ok := raw.(bool)
if !ok {
return fmt.Errorf("valid must be boolean, got %T", raw)
}
if validFound && valid != value {
return fmt.Errorf("conflicting valid values")
}
valid, validFound = value, true
}
for _, key := range []string{"flowId", "workflowId"} {
raw, exists := object[key]
if !exists {
continue
}
value, ok := raw.(string)
value = strings.TrimSpace(value)
if !ok || value == "" {
return fmt.Errorf("%s must be a non-empty string", key)
}
if flowID != "" && flowID != value {
return fmt.Errorf("conflicting workflow IDs")
}
flowID = value
}
for _, key := range []string{"data", "result", "response"} {
if nested, ok := object[key].(map[string]any); ok {
if err := visit(nested); err != nil {
return err
}
}
}
return nil
}
if envelope == nil {
return false, false, "", fmt.Errorf("empty response")
}
if err := visit(envelope); err != nil {
return false, false, "", err
}
return valid, validFound, flowID, nil
}
func validateWorkflowRunFlags(cmd *cobra.Command, _ []string) error {
tableID, _ := cmd.Flags().GetString("table-id")
tableID = strings.TrimSpace(tableID)
@@ -1184,6 +1263,290 @@ func runAitableViewUpdateArray(cmd *cobra.Command, blockKey string) error {
return callUpdateViewWithBlock(baseID, tableID, viewID, blockKey, cfgMap[blockKey], nil)
}
func runAitableViewUpdateFilter(cmd *cobra.Command) error {
baseID, tableID, viewID, _, err := viewUpdateCommonPreflight(cmd, "filter", nil, false)
if err != nil {
return err
}
jsonStr, _ := cmd.Flags().GetString("json")
if jsonStr == "" {
return fmt.Errorf("必须指定 --json 传入 filter JSON 数组")
}
var parsed any
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil {
return fmt.Errorf("--json 解析失败: %v", err)
}
cfgMap := map[string]any{"filter": parsed}
if err := normalizeViewConfigBlock(cfgMap); err != nil {
return err
}
filter, _ := cfgMap["filter"].([]any)
fieldTypes, err := loadAitableFieldTypes(context.Background(), baseID, tableID)
if err != nil {
return err
}
if err := validateAitableViewFilter(filter, fieldTypes); err != nil {
return err
}
toolArgs := map[string]any{
"baseId": baseID, "tableId": tableID, "viewId": viewID,
"config": map[string]any{"filter": filter},
}
if deps.Caller.DryRun() {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true, "executed": false, "tool": "update_view", "arguments": toolArgs,
})
}
if _, err := callMCPToolReturnTextOnServer(context.Background(), "aitable", "update_view", toolArgs); err != nil {
return err
}
var actual any
var readBackErr error
for attempt := 0; attempt < aitableViewFilterReadbackAttempts; attempt++ {
if attempt > 0 {
backoff := time.Duration(1<<(attempt-1)) * time.Second
if backoff > 8*time.Second {
backoff = 8 * time.Second
}
aitableViewFilterReadbackSleep(backoff)
}
view, _, err := getViewRaw(context.Background(), baseID, tableID, viewID)
if err != nil {
readBackErr = err
continue
}
actualViewID, _ := view["viewId"].(string)
if actualViewID != viewID {
readBackErr = fmt.Errorf("update_view read-back returned viewId %q, want %q", actualViewID, viewID)
continue
}
actual = walkViewPath(view, "filter")
if persistedViewFilterMatches(actual, filter) {
readBackErr = nil
break
}
readBackErr = fmt.Errorf("update_view filter read-back mismatch: got %s, want %s", compactJSON(actual), compactJSON(filter))
}
if readBackErr != nil {
return &CLIError{Code: CodeMCPToolError, Message: readBackErr.Error(), Suggestion: "重新读取 view get filter,确认服务端支持所用字段类型和操作符后再试"}
}
return deps.Out.PrintJSON(map[string]any{
"success": true,
"data": map[string]any{"baseId": baseID, "tableId": tableID, "viewId": viewID, "filter": filter, "verified": true},
})
}
const aitableViewFilterReadbackAttempts = 6
var aitableViewFilterReadbackSleep = time.Sleep
func persistedViewFilterMatches(actual any, expected []any) bool {
if reflect.DeepEqual(actual, expected) {
return true
}
root, ok := actual.(map[string]any)
if !ok || root["operator"] != "and" {
return false
}
operands, ok := root["operands"].([]any)
return ok && reflect.DeepEqual(operands, expected)
}
func loadAitableFieldTypes(ctx context.Context, baseID, tableID string) (map[string]string, error) {
raw, err := callMCPReadToolReturnTextOnServer(ctx, "aitable", "get_fields", map[string]any{"baseId": baseID, "tableId": tableID})
if err != nil {
return nil, err
}
var payload any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil, fmt.Errorf("get_fields response is not valid JSON: %v", err)
}
fields, ok := findAitableObjectList(payload, "fields", "fieldList")
if !ok {
return nil, fmt.Errorf("get_fields response is missing the fields collection")
}
types := make(map[string]string, len(fields))
for index, field := range fields {
fieldID, _ := field["fieldId"].(string)
if strings.TrimSpace(fieldID) == "" {
fieldID, _ = field["id"].(string)
}
fieldType, _ := field["type"].(string)
if strings.TrimSpace(fieldType) == "" {
fieldType, _ = field["fieldType"].(string)
}
if strings.TrimSpace(fieldID) == "" || strings.TrimSpace(fieldType) == "" {
return nil, fmt.Errorf("get_fields field %d is missing fieldId or type", index)
}
types[strings.TrimSpace(fieldID)] = strings.TrimSpace(fieldType)
}
return types, nil
}
func findAitableObjectList(value any, names ...string) ([]map[string]any, bool) {
wanted := make([]string, 0, len(names))
seen := make(map[string]bool, len(names))
for _, name := range names {
name = strings.ToLower(name)
if !seen[name] {
wanted = append(wanted, name)
seen[name] = true
}
}
var walk func(any) ([]map[string]any, bool)
walk = func(current any) ([]map[string]any, bool) {
switch typed := current.(type) {
case map[string]any:
keys := make([]string, 0, len(typed))
for key := range typed {
keys = append(keys, key)
}
sort.Strings(keys)
for _, name := range wanted {
for _, key := range keys {
if !strings.EqualFold(key, name) {
continue
}
items, ok := typed[key].([]any)
if !ok {
return nil, false
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
object, ok := item.(map[string]any)
if !ok {
return nil, false
}
out = append(out, object)
}
return out, true
}
}
for _, key := range keys {
if found, ok := walk(typed[key]); ok {
return found, true
}
}
case []any:
for _, child := range typed {
if found, ok := walk(child); ok {
return found, true
}
}
}
return nil, false
}
return walk(value)
}
func validateAitableViewFilter(filter []any, fieldTypes map[string]string) error {
for index, raw := range filter {
condition, ok := raw.(map[string]any)
if !ok {
return fmt.Errorf("filter[%d] must be an object", index)
}
if err := validateAitableViewFilterCondition(condition, fieldTypes); err != nil {
return fmt.Errorf("filter[%d]: %w", index, err)
}
}
return nil
}
func validateAitableViewFilterCondition(condition map[string]any, fieldTypes map[string]string) error {
operator, _ := condition["operator"].(string)
operator = strings.TrimSpace(operator)
if operator == "" || !validFilterOperators[operator] {
return fmt.Errorf("unsupported operator %q", operator)
}
operands, ok := condition["operands"].([]any)
if !ok {
return fmt.Errorf("operator %s requires an operands array", operator)
}
if operator == "and" || operator == "or" {
return fmt.Errorf("logical operator %s is not supported by the persisted view protocol; pass a flat array of leaf conditions (combined as AND)", operator)
}
wantOperands := 2
if operator == "exist" || operator == "un_exist" {
wantOperands = 1
}
if len(operands) != wantOperands {
return fmt.Errorf("operator %s requires %d operands", operator, wantOperands)
}
fieldID, ok := operands[0].(string)
fieldID = strings.TrimSpace(fieldID)
if !ok || fieldID == "" {
return fmt.Errorf("operator %s requires a fieldId as its first operand", operator)
}
fieldType, exists := fieldTypes[fieldID]
if !exists {
return fmt.Errorf("filter references unknown fieldId %q", fieldID)
}
if isAitableDateFieldType(fieldType) && !isAitableDateFilterOperator(operator) {
return fmt.Errorf("operator %s is invalid for %s field %s; use date_eq/before/after/not_before/not_after/exist/un_exist", operator, fieldType, fieldID)
}
if operator == "any_of" || operator == "all_of" || operator == "none_of" {
if !strings.EqualFold(fieldType, "multipleSelect") && !strings.EqualFold(fieldType, "multiSelect") {
return fmt.Errorf("operator %s requires a multipleSelect field, got %s", operator, fieldType)
}
if operator == "any_of" {
if values, ok := operands[1].([]any); ok {
if err := validateAitableMultiSelectOptionNames(values); err != nil {
return err
}
return fmt.Errorf("multipleSelect any_of with multiple values is not supported by the persisted view protocol; use one scalar option or separate views")
}
}
if err := validateAitableMultiSelectFilterValue(operands[1]); err != nil {
return err
}
}
return nil
}
func validateAitableMultiSelectOptionNames(values []any) error {
if len(values) == 0 {
return fmt.Errorf("multipleSelect any_of array must not be empty")
}
for index, value := range values {
text, ok := value.(string)
text = strings.TrimSpace(text)
if !ok || text == "" {
return fmt.Errorf("multipleSelect any_of value %d must be a non-empty option-name string", index)
}
}
return nil
}
func isAitableDateFieldType(fieldType string) bool {
return strings.EqualFold(fieldType, "date") ||
strings.EqualFold(fieldType, "createdTime") ||
strings.EqualFold(fieldType, "lastModifiedTime")
}
func isAitableDateFilterOperator(operator string) bool {
switch operator {
case "date_eq", "before", "after", "not_before", "not_after", "exist", "un_exist":
return true
default:
return false
}
}
func validateAitableMultiSelectFilterValue(value any) error {
if typed, ok := value.(string); ok && strings.TrimSpace(typed) != "" {
return nil
}
return fmt.Errorf("multipleSelect filter value must be one option-name string; the persisted view protocol does not support a multi-value OR expression")
}
func compactJSON(value any) string {
raw, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("%#v", value)
}
return string(raw)
}
func newAitableCommand() *cobra.Command {
// Product-level Agent routing Decl (migrated from selection/aitable.json
// products.aitable). Catalog assembly stamps provenance contract_final.
@@ -4076,7 +4439,7 @@ colorConfigs (JSON 数组) / officialHoliday (bool)。`,
若传对象会自动 wrap 为数组;其他非法格式拒绝。`,
Example: ` dws aitable view update filter --view-id VIEW_ID --json '[{"operator":"and","operands":[{"operator":"eq","operands":["fldX","value"]}]}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
return runAitableViewUpdateArray(cmd, "filter")
return runAitableViewUpdateFilter(cmd)
},
}
DeclareLeafMetadata(viewUpdateFilterCmd, LeafSpec{
@@ -5282,7 +5645,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
}
// create_workflow is non-idempotent. Bypass the retry wrapper to
// prevent an uncertain first response from creating a duplicate.
return callMCPToolOnServer("aitable", "create_workflow", toolArgs)
return executeAitableWorkflowPublish("create_workflow", toolArgs)
},
}
DeclareLeafMetadata(workflowCreateCmd, LeafSpec{
@@ -5376,7 +5739,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
if locale, _ := cmd.Flags().GetString("locale"); strings.TrimSpace(locale) != "" {
toolArgs["locale"] = locale
}
return callAitableTool("update_workflow", toolArgs)
return executeAitableWorkflowPublish("update_workflow", toolArgs)
},
}
DeclareLeafMetadata(workflowUpdateCmd, LeafSpec{
@@ -8059,6 +8422,416 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
sectionMoveNodeCmd,
)
// ── datasource: 数据源同步管理 ──────────────────────────────
datasourceCmd := &cobra.Command{Use: "datasource", Short: "数据源同步管理", RunE: groupRunE}
datasourceGetConfigCmd := &cobra.Command{
Use: "get-config",
Short: "获取数据源表同步配置",
Example: ` dws aitable datasource get-config --base-id BASE_ID --table-id TABLE_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "table-id"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
return callAitableTool("get_datasource_config", map[string]any{
"baseId": baseID,
"tableId": mustGetFlag(cmd, "table-id"),
})
},
}
DeclareLeafMetadata(datasourceGetConfigCmd, LeafSpec{
Safety: aitableSafetyRead(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_get_config",
CanonicalPath: "aitable.datasource_get_config",
CLIPath: "aitable datasource get-config",
PrimaryCLIPath: "aitable datasource get-config",
},
Description: "获取数据源表的同步配置信息。",
Interface: aitableMCPInterface("get_datasource_config"),
Selection: contract.SelectionSpec{
AgentSummary: "获取数据源表的同步配置信息。",
UseWhen: []string{"查看已有数据源表的配置详情时"},
AvoidWhen: []string{"更新配置用 datasource update;查询同步状态用 datasource sync-status"},
Examples: []string{"dws aitable datasource get-config --base-id <BASE_ID> --table-id <TABLE_ID>"},
},
},
})
datasourceGetConfigCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceGetConfigCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
datasourceListSourcesCmd := &cobra.Command{
Use: "list-sources",
Short: "列出可用数据源来源",
Example: ` dws aitable datasource list-sources --base-id BASE_ID --datasource-type OA`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "datasource-type"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
return callAitableTool("list_datasource_sources", map[string]any{
"baseId": baseID,
"datasourceType": mustGetFlag(cmd, "datasource-type"),
})
},
}
DeclareLeafMetadata(datasourceListSourcesCmd, LeafSpec{
Safety: aitableSafetyRead(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_list_sources",
CanonicalPath: "aitable.datasource_list_sources",
CLIPath: "aitable datasource list-sources",
PrimaryCLIPath: "aitable datasource list-sources",
},
Description: "列出指定 Base 下可用的数据源条目。",
Interface: aitableMCPInterface("list_datasource_sources"),
Selection: contract.SelectionSpec{
AgentSummary: "列出指定 Base 下可用的数据源条目(OA 审批模板等)。",
UseWhen: []string{"创建或更新数据源前需要查看可用来源时"},
AvoidWhen: []string{"获取字段结构用 datasource get-fields"},
Examples: []string{"dws aitable datasource list-sources --base-id <BASE_ID> --datasource-type OA"},
},
},
})
datasourceListSourcesCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceListSourcesCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
validateJSONObject := func(flag, raw string) error {
var v any
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return fmt.Errorf("--%s must be a valid JSON object: %w", flag, err)
}
if _, ok := v.(map[string]any); !ok {
return fmt.Errorf("--%s must be a JSON object, got %T", flag, v)
}
return nil
}
validateAutoSyncSetting := func(raw string) error {
return validateJSONObject("auto-sync-setting", raw)
}
datasourceGetFieldsCmd := &cobra.Command{
Use: "get-fields",
Short: "获取数据源可同步字段列表",
Example: ` dws aitable datasource get-fields --base-id BASE_ID --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "datasource-type", "source-config"); err != nil {
return err
}
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
return callAitableTool("get_datasource_fields", map[string]any{
"baseId": baseID,
"datasourceType": mustGetFlag(cmd, "datasource-type"),
"sourceConfig": mustGetFlag(cmd, "source-config"),
})
},
}
DeclareLeafMetadata(datasourceGetFieldsCmd, LeafSpec{
Safety: aitableSafetyRead(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_get_fields",
CanonicalPath: "aitable.datasource_get_fields",
CLIPath: "aitable datasource get-fields",
PrimaryCLIPath: "aitable datasource get-fields",
},
Description: "获取指定数据源来源的可同步字段列表。",
Interface: aitableMCPInterface("get_datasource_fields"),
Selection: contract.SelectionSpec{
AgentSummary: "获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键)。",
UseWhen: []string{"创建或更新数据源前需要查看可同步字段以决定 field-ids 时"},
AvoidWhen: []string{"列出可用来源用 datasource list-sources"},
Examples: []string{`dws aitable datasource get-fields --base-id <BASE_ID> --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`},
},
},
})
datasourceGetFieldsCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceGetFieldsCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
datasourceGetFieldsCmd.Flags().String("source-config", "", "源配置 JSON 字符串,需含 processCode、name、iconUrl、url、dataType 及对应时间字段 (必填)")
datasourceCreateCmd := &cobra.Command{
Use: "create",
Short: "创建数据源表并触发首次同步",
Example: ` dws aitable datasource create --base-id BASE_ID --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "datasource-type", "source-config"); err != nil {
return err
}
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
auto, _ := cmd.Flags().GetBool("auto")
toolArgs := map[string]any{
"baseId": baseID,
"datasourceType": mustGetFlag(cmd, "datasource-type"),
"sourceConfig": mustGetFlag(cmd, "source-config"),
"auto": auto,
}
if v, _ := cmd.Flags().GetString("field-ids"); v != "" {
toolArgs["fieldIds"] = parseCSVValues(v)
}
if v, _ := cmd.Flags().GetString("auto-sync-setting"); v != "" {
if err := validateAutoSyncSetting(v); err != nil {
return err
}
toolArgs["autoSyncSetting"] = v
}
return callAitableTool("create_datasource", toolArgs)
},
}
DeclareLeafMetadata(datasourceCreateCmd, LeafSpec{
Safety: aitableSafetyWrite(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_create",
CanonicalPath: "aitable.datasource_create",
CLIPath: "aitable datasource create",
PrimaryCLIPath: "aitable datasource create",
},
Description: "为指定 Base 创建数据源表并触发首次全量同步。",
Interface: aitableMCPInterface("create_datasource"),
Selection: contract.SelectionSpec{
AgentSummary: "为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。",
UseWhen: []string{"需要将外部数据源接入 AI 表格、创建新的数据源表时"},
AvoidWhen: []string{"已有数据源表改配置用 datasource update;仅触发同步用 datasource sync"},
Examples: []string{`dws aitable datasource create --base-id <BASE_ID> --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`},
},
Parameters: []contract.ParamDecl{
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
{Name: "datasource-type", Property: "datasourceType", Required: boolPtr(true)},
{Name: "source-config", Property: "sourceConfig", Required: boolPtr(true)},
{Name: "auto", Property: "auto"},
{Name: "field-ids", Property: "fieldIds", InterfaceType: "array"},
{Name: "auto-sync-setting", Property: "autoSyncSetting"},
},
},
})
datasourceCreateCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceCreateCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
datasourceCreateCmd.Flags().String("source-config", "", "源配置 JSON 字符串,须从 list-sources 原样透传 processCode/name/iconUrl/url,并设置 dataType 及对应时间字段 (必填)")
datasourceCreateCmd.Flags().Bool("auto", false, "是否开启自动同步,默认 false;创建新数据源表时始终下发给下游")
datasourceCreateCmd.Flags().String("field-ids", "", "需要同步的字段 ID 列表,逗号分隔;不传时同步全部字段")
datasourceCreateCmd.Flags().String("auto-sync-setting", "", "自动同步频率配置 JSON 字符串,仅在 --auto=true 时生效。字段:syncType(必填,hourly/scheduled)、hourlyInterval(syncType=hourly 时必填)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(HH:mm)、selectedMonthDays(scheduleType=monthly 时)、selectedWeekdays(scheduleType=weekly 时)、skipNonWorkingDay")
datasourceUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新数据源表同步配置并触发同步",
Example: ` dws aitable datasource update --base-id BASE_ID --table-id TABLE_ID --auto`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "table-id"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
toolArgs := map[string]any{
"baseId": baseID,
"tableId": mustGetFlag(cmd, "table-id"),
}
if cmd.Flags().Changed("source-config") {
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
return err
}
toolArgs["sourceConfig"] = mustGetFlag(cmd, "source-config")
}
if cmd.Flags().Changed("auto") {
auto, _ := cmd.Flags().GetBool("auto")
toolArgs["auto"] = auto
}
if cmd.Flags().Changed("field-ids") {
v := mustGetFlag(cmd, "field-ids")
if v == "" {
return fmt.Errorf("--field-ids 显式提供时不能为空,如需保持默认请勿传入")
}
toolArgs["fieldIds"] = parseCSVValues(v)
}
if cmd.Flags().Changed("auto-sync-setting") {
v := mustGetFlag(cmd, "auto-sync-setting")
if v == "" {
return fmt.Errorf("--auto-sync-setting 显式提供时不能为空,如需保持默认请勿传入")
}
if err := validateAutoSyncSetting(v); err != nil {
return err
}
toolArgs["autoSyncSetting"] = v
}
if !cmd.Flags().Changed("source-config") && !cmd.Flags().Changed("auto") && !cmd.Flags().Changed("field-ids") && !cmd.Flags().Changed("auto-sync-setting") {
return fmt.Errorf("至少需要一个配置变更:--source-config、--auto、--field-ids 或 --auto-sync-setting;仅触发同步请使用 datasource sync")
}
return callAitableTool("update_datasource_config", toolArgs)
},
}
DeclareLeafMetadata(datasourceUpdateCmd, LeafSpec{
Safety: aitableSafetyWrite(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_update",
CanonicalPath: "aitable.datasource_update",
CLIPath: "aitable datasource update",
PrimaryCLIPath: "aitable datasource update",
},
Description: "更新已有数据源表的同步配置并触发一次同步。",
Interface: aitableMCPInterface("update_datasource_config"),
Selection: contract.SelectionSpec{
AgentSummary: "更新已有数据源表的同步配置并触发一次同步。",
UseWhen: []string{"需要修改已有数据源表的配置(更换模板、调整字段、开关自动同步)时"},
AvoidWhen: []string{"创建新数据源表用 datasource create;仅触发同步用 datasource sync"},
Examples: []string{
"dws aitable datasource update --base-id <BASE_ID> --table-id <TABLE_ID> --auto",
`dws aitable datasource update --base-id <BASE_ID> --table-id <TABLE_ID> --source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
},
},
Parameters: []contract.ParamDecl{
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
{Name: "source-config", Property: "sourceConfig"},
{Name: "auto", Property: "auto"},
{Name: "field-ids", Property: "fieldIds", InterfaceType: "array"},
{Name: "auto-sync-setting", Property: "autoSyncSetting"},
},
},
})
datasourceUpdateCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceUpdateCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
datasourceUpdateCmd.Flags().String("source-config", "", "可选。新的源配置 JSON 字符串,不传时保持原配置;传入时整体覆盖,须含 processCode、name、iconUrl、url、dataType 及对应时间字段")
datasourceUpdateCmd.Flags().Bool("auto", false, "可选。是否开启自动同步;仅显式设置时下发给下游,省略时保持原设置")
datasourceUpdateCmd.Flags().String("field-ids", "", "需要同步的字段 ID 列表,逗号分隔;不传时保持现有配置(创建时默认为全部字段)")
datasourceUpdateCmd.Flags().String("auto-sync-setting", "", "可选。自动同步频率配置 JSON 字符串,仅在显式设置 --auto=true 时生效;省略时保持原有自动同步频率配置。字段:syncType(必填,hourly/scheduled)、hourlyInterval(syncType=hourly 时必填)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(HH:mm)、selectedMonthDays(scheduleType=monthly 时)、selectedWeekdays(scheduleType=weekly 时)、skipNonWorkingDay")
datasourceSyncCmd := &cobra.Command{
Use: "sync",
Short: "触发数据源表手动同步",
Example: ` dws aitable datasource sync --base-id BASE_ID --table-ids TBL1,TBL2`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "table-ids"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
tableIDs := parseCSVValues(mustGetFlag(cmd, "table-ids"))
if len(tableIDs) < 1 || len(tableIDs) > 5 {
return fmt.Errorf("--table-ids requires 1-5 table IDs, got %d", len(tableIDs))
}
return callAitableTool("run_datasource_sync", map[string]any{
"baseId": baseID,
"tableIds": tableIDs,
})
},
}
DeclareLeafMetadata(datasourceSyncCmd, LeafSpec{
Safety: aitableSafetyWrite(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_sync",
CanonicalPath: "aitable.datasource_sync",
CLIPath: "aitable datasource sync",
PrimaryCLIPath: "aitable datasource sync",
},
Description: "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回。",
Interface: aitableMCPInterface("run_datasource_sync"),
Selection: contract.SelectionSpec{
AgentSummary: "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。",
UseWhen: []string{"需要手动触发已有数据源表的数据同步时"},
AvoidWhen: []string{"创建新数据源表用 datasource create;更新配置用 datasource update"},
Examples: []string{"dws aitable datasource sync --base-id <BASE_ID> --table-ids TBL1,TBL2"},
},
Parameters: []contract.ParamDecl{
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
{Name: "table-ids", Property: "tableIds", Required: boolPtr(true)},
},
},
})
datasourceSyncCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceSyncCmd.Flags().String("table-ids", "", "待触发同步的数据源表 ID 列表,逗号分隔,1-5 个 (必填)")
datasourceSyncStatusCmd := &cobra.Command{
Use: "sync-status",
Short: "按任务 ID 查询数据源同步任务状态",
Example: ` dws aitable datasource sync-status --base-id BASE_ID --table-id TABLE_ID --task-ids TASK1,TASK2`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "table-id", "task-ids"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
ids := parseCSVValues(mustGetFlag(cmd, "task-ids"))
if len(ids) < 1 || len(ids) > 5 {
return fmt.Errorf("--task-ids requires 1-5 task IDs, got %d", len(ids))
}
toolArgs := map[string]any{
"baseId": baseID,
"tableId": mustGetFlag(cmd, "table-id"),
"taskIds": ids,
}
return callAitableTool("get_datasource_sync_status", toolArgs)
},
}
DeclareLeafMetadata(datasourceSyncStatusCmd, LeafSpec{
Safety: aitableSafetyRead(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_sync_status",
CanonicalPath: "aitable.datasource_sync_status",
CLIPath: "aitable datasource sync-status",
PrimaryCLIPath: "aitable datasource sync-status",
},
Description: "按任务 ID 查询数据源同步任务状态(RUNNING/FINISHED/FAILED)。",
Interface: aitableMCPInterface("get_datasource_sync_status"),
Selection: contract.SelectionSpec{
AgentSummary: "按任务 ID 批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。",
UseWhen: []string{"触发同步后需要按任务 ID 查询任务是否完成时"},
AvoidWhen: []string{"触发同步用 datasource sync"},
Examples: []string{"dws aitable datasource sync-status --base-id <BASE_ID> --table-id <TABLE_ID> --task-ids TASK1"},
},
Parameters: []contract.ParamDecl{
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
{Name: "task-ids", Property: "taskIds", Required: boolPtr(true)},
},
},
})
datasourceSyncStatusCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceSyncStatusCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
datasourceSyncStatusCmd.Flags().String("task-ids", "", "待查询的同步任务 ID 列表,逗号分隔,1-5 个 (必填)")
datasourceCmd.AddCommand(
datasourceGetConfigCmd, datasourceListSourcesCmd, datasourceGetFieldsCmd,
datasourceCreateCmd, datasourceUpdateCmd,
datasourceSyncCmd, datasourceSyncStatusCmd,
)
// 组装 aitable 命令树
root.AddCommand(
baseCmd, tableCmd, fieldCmd,
@@ -8069,6 +8842,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
attachmentCmd, templateCmd,
advpermCmd,
sectionCmd,
datasourceCmd,
)
// 批量注册 --base 作为 --base-id 的隐藏别名
@@ -361,3 +361,228 @@ func TestCrossPlatformCoverageAitableDeleteCancellationEdges(t *testing.T) {
_ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{}, args...)
}
}
func TestCrossPlatformCoverageAitableViewFilterValidationAndReadBack(t *testing.T) {
testseam.Swap(t, &aitableViewFilterReadbackSleep, func(time.Duration) {})
oldArgs := os.Args
t.Cleanup(func() { os.Args = oldArgs })
os.Args = []string{"dws", "aitable"}
filter := `[{"operator":"eq","operands":["fldA","x"]},{"operator":"any_of","operands":["fldMulti","A"]}]`
fields := `{"data":{"fields":[{"fieldId":"fldA","type":"text"},{"fieldId":"fldB","type":"text"},{"fieldId":"fldMulti","type":"multipleSelect"}]}}`
readBack := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":[{"operator":"eq","operands":["fldA","x"]},{"operator":"any_of","operands":["fldMulti","A"]}]}]}}`
t.Run("flat leaf filters are written then exactly verified", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`, readBack}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter)
if err != nil || len(caller.calls) != 3 || caller.calls[0].tool != "get_fields" || caller.calls[1].tool != "update_view" || caller.calls[2].tool != "get_views" {
t.Fatalf("verified view filter = err:%v calls:%#v", err, caller.calls)
}
config, _ := caller.calls[1].args["config"].(map[string]any)
if _, ok := config["filter"].([]any); !ok {
t.Fatalf("update_view filter encoding = %#v", caller.calls[1].args)
}
})
t.Run("logical groups fail closed before write", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"or","operands":[{"operator":"eq","operands":["fldA","x"]},{"operator":"eq","operands":["fldB","y"]}]}]`)
if err == nil || !strings.Contains(err.Error(), "persisted view protocol") || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
t.Fatalf("logical filter group fail-closed = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("unknown field is rejected before write", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"eq","operands":["missing","x"]}]`)
if err == nil || !strings.Contains(err.Error(), "unknown fieldId") || len(caller.calls) != 1 {
t.Fatalf("unknown filter field = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("eventual persisted and wrapper is retried then verified", func(t *testing.T) {
input := `[{"operator":"any_of","operands":["fldMulti","A"]}]`
stale := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":{"operator":"and","operands":[]}}]}}`
terminal := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":{"operator":"and","operands":[{"operator":"any_of","operands":["fldMulti","A"]}]}}]}}`
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`, stale, terminal}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
if err != nil || len(caller.calls) != 4 || caller.calls[2].tool != "get_views" || caller.calls[3].tool != "get_views" {
t.Fatalf("eventual wrapped filter = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("multi-select operator rejects text field", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"any_of","operands":["fldA",["A"]]}]`)
if err == nil || !strings.Contains(err.Error(), "requires a multipleSelect field") || len(caller.calls) != 1 {
t.Fatalf("wrong filter field type = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("multi-select array fails closed before write", func(t *testing.T) {
input := `[{"operator":"any_of","operands":["fldMulti",["A","B"]]}]`
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
if err == nil || !strings.Contains(err.Error(), "persisted view protocol") || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
t.Fatalf("multi-select array fail-closed = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("multi-select invalid array value fails before write", func(t *testing.T) {
input := `[{"operator":"any_of","operands":["fldMulti",["A",""]]}]`
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
if err == nil || !strings.Contains(err.Error(), "non-empty option-name") || len(caller.calls) != 1 {
t.Fatalf("invalid multi-select array = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("date and system-time fields require date operators", func(t *testing.T) {
fieldTypes := map[string]string{"date": "date", "created": "createdTime", "modified": "lastModifiedTime"}
for fieldID := range fieldTypes {
valid := []any{map[string]any{"operator": "date_eq", "operands": []any{fieldID, "2026-08-18"}}}
if err := validateAitableViewFilter(valid, fieldTypes); err != nil {
t.Fatalf("date_eq for %s: %v", fieldID, err)
}
invalid := []any{map[string]any{"operator": "eq", "operands": []any{fieldID, "2026-08-18"}}}
if err := validateAitableViewFilter(invalid, fieldTypes); err == nil || !strings.Contains(err.Error(), "invalid for") {
t.Fatalf("eq for %s = %v, want date-operator error", fieldID, err)
}
}
})
t.Run("dry-run validates but performs no write or read-back", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}, dryRun: true}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter, "--dry-run")
if err != nil || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
t.Fatalf("view filter dry-run = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("read-back mismatch is not success", func(t *testing.T) {
responses := []string{fields, `{"success":true}`}
for range aitableViewFilterReadbackAttempts {
responses = append(responses, `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":[]}]}}`)
}
caller := &aitableTestCaller{responses: responses}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter)
if err == nil || !strings.Contains(err.Error(), "read-back mismatch") || len(caller.calls) != 2+aitableViewFilterReadbackAttempts {
t.Fatalf("mismatched filter readback = err:%v calls:%#v", err, caller.calls)
}
})
}
func TestCrossPlatformCoverageAitableViewFilterFailureAndShapeEdges(t *testing.T) {
testseam.Swap(t, &aitableViewFilterReadbackSleep, func(time.Duration) {})
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "aitable"}
fields := `{"data":{"fields":[{"fieldId":"fldA","type":"text"}]}}`
filter := `[{"operator":"eq","operands":["fldA","x"]}]`
args := []string{"view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json=" + filter}
t.Run("update transport error", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}, errors: []error{nil, context.Canceled}}
if err := runAitableCoverageCommand(t, caller, args...); err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || len(caller.calls) != 2 {
t.Fatalf("update error = %v, calls=%#v", err, caller.calls)
}
})
t.Run("readback transport errors exhaust", func(t *testing.T) {
errs := []error{nil, nil}
for range aitableViewFilterReadbackAttempts {
errs = append(errs, context.DeadlineExceeded)
}
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`}, errors: errs}
if err := runAitableCoverageCommand(t, caller, args...); err == nil || len(caller.calls) != 2+aitableViewFilterReadbackAttempts {
t.Fatalf("readback errors = %v, calls=%d", err, len(caller.calls))
}
})
t.Run("readback wrong identity exhausts", func(t *testing.T) {
responses := []string{fields, `{"success":true}`}
for range aitableViewFilterReadbackAttempts {
responses = append(responses, `{"data":{"views":[{"viewId":"other","filter":[]}]}}`)
}
caller := &aitableTestCaller{responses: responses}
if err := runAitableCoverageCommand(t, caller, args...); err == nil || !strings.Contains(err.Error(), "returned viewId") {
t.Fatalf("wrong readback identity = %v", err)
}
})
loadCases := []struct {
name string
response string
callErr error
wantError string
}{
{name: "transport", callErr: context.Canceled, wantError: "context canceled"},
{name: "invalid json", response: `{`, wantError: "not valid JSON"},
{name: "missing collection", response: `{}`, wantError: "missing the fields collection"},
{name: "missing identity", response: `{"fields":[{"type":"text"}]}`, wantError: "missing fieldId or type"},
}
for _, tc := range loadCases {
t.Run("load fields "+tc.name, func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{tc.response}, errors: []error{tc.callErr}}
installAitableDeps(t, caller)
if _, err := loadAitableFieldTypes(context.Background(), "b", "t"); err == nil || !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("load fields error = %v, want %q", err, tc.wantError)
}
})
}
t.Run("load legacy field keys", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{`{"fieldList":[{"id":"legacy","fieldType":"text"}]}`}}
installAitableDeps(t, caller)
got, err := loadAitableFieldTypes(context.Background(), "b", "t")
if err != nil || got["legacy"] != "text" {
t.Fatalf("legacy field keys = %#v, %v", got, err)
}
})
if _, ok := findAitableObjectList(map[string]any{"fields": "bad"}, "fields"); ok {
t.Fatal("scalar fields collection must fail")
}
if _, ok := findAitableObjectList(map[string]any{"fields": []any{"bad"}}, "fields"); ok {
t.Fatal("scalar field item must fail")
}
if got, ok := findAitableObjectList([]any{"skip", map[string]any{"nested": map[string]any{"fields": []any{map[string]any{"fieldId": "f"}}}}}, "fields"); !ok || len(got) != 1 {
t.Fatalf("recursive fields = %#v, %v", got, ok)
}
if got, ok := findAitableObjectList(map[string]any{
"fieldList": []any{map[string]any{"fieldId": "legacy"}},
"fields": []any{map[string]any{"fieldId": "canonical"}},
}, "fields", "fieldList"); !ok || len(got) != 1 || got[0]["fieldId"] != "canonical" {
t.Fatalf("declared collection priority = %#v, %v", got, ok)
}
invalidFilters := []struct {
filter []any
want string
}{
{filter: []any{"bad"}, want: "must be an object"},
{filter: []any{map[string]any{"operator": "bogus", "operands": []any{}}}, want: "unsupported operator"},
{filter: []any{map[string]any{"operator": "eq", "operands": "bad"}}, want: "requires an operands array"},
{filter: []any{map[string]any{"operator": "and", "operands": []any{}}}, want: "logical operator"},
{filter: []any{map[string]any{"operator": "exist", "operands": []any{"f", "extra"}}}, want: "requires 1 operands"},
{filter: []any{map[string]any{"operator": "eq", "operands": []any{1, "x"}}}, want: "requires a fieldId"},
{filter: []any{map[string]any{"operator": "any_of", "operands": []any{"multi", 1}}}, want: "one option-name string"},
}
for _, tc := range invalidFilters {
if err := validateAitableViewFilter(tc.filter, map[string]string{"f": "text", "multi": "multipleSelect"}); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("validate filter %#v = %v, want %q", tc.filter, err, tc.want)
}
}
if err := validateAitableMultiSelectOptionNames(nil); err == nil {
t.Fatal("empty any_of array must fail")
}
if err := validateAitableMultiSelectOptionNames([]any{"ok", 1}); err == nil {
t.Fatal("non-string any_of option must fail")
}
if err := validateAitableMultiSelectOptionNames([]any{"first", " second "}); err != nil {
t.Fatalf("valid any_of option names = %v", err)
}
if got := compactJSON(make(chan int)); !strings.HasPrefix(got, "(chan int)") {
t.Fatalf("compactJSON fallback = %q", got)
}
if persistedViewFilterMatches("bad", nil) || persistedViewFilterMatches(map[string]any{"operator": "or"}, nil) {
t.Fatal("invalid persisted wrapper must not match")
}
}
+5 -1
View File
@@ -24,6 +24,7 @@ type aitableTestCaller struct {
responses []string
errors []error
calls []aitableTestCall
dryRun bool
}
func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
@@ -38,8 +39,11 @@ func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, arg
}
return textToolResult(response), nil
}
func (c *aitableTestCaller) CallReadTool(ctx context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
return c.CallTool(ctx, server, tool, args)
}
func (*aitableTestCaller) Format() string { return "json" }
func (*aitableTestCaller) DryRun() bool { return false }
func (c *aitableTestCaller) DryRun() bool { return c.dryRun }
func (*aitableTestCaller) Fields() string { return "" }
func (*aitableTestCaller) JQ() string { return "" }
+462
View File
@@ -0,0 +1,462 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package helpers
import (
"context"
"io"
"os"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type aitableDatasourceCaller struct {
calls []aitableTestCall
}
func (c *aitableDatasourceCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, aitableTestCall{server: server, tool: tool, args: args})
return &edition.ToolResult{Content: []edition.ContentBlock{{
Type: "text",
Text: `{"status":"success","data":{"tableId":"tbl_test","taskId":"task_test"}}`,
}}}, nil
}
func (*aitableDatasourceCaller) Format() string { return "json" }
func (*aitableDatasourceCaller) DryRun() bool { return false }
func (*aitableDatasourceCaller) Fields() string { return "" }
func (*aitableDatasourceCaller) JQ() string { return "" }
func runAitableDatasourceCommand(t *testing.T, args ...string) (*aitableDatasourceCaller, error) {
t.Helper()
testseam.Protect(t, &os.Args)
caller := &aitableDatasourceCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
os.Args = append([]string{"dws", "aitable", "datasource"}, args...)
root := newAitableCommand()
root.SetArgs(append([]string{"datasource"}, args...))
return caller, root.Execute()
}
func TestAitableDatasourceSyncRejectsMissingTableIDs(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123")
if err == nil || !strings.Contains(err.Error(), "table-ids") {
t.Fatalf("error = %v, want table-ids required", err)
}
}
func TestAitableDatasourceSyncRejectsTooManyTableIDs(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
"--table-ids", "T1,T2,T3,T4,T5,T6")
if err == nil || !strings.Contains(err.Error(), "1-5") {
t.Fatalf("error = %v, want 1-5 limit", err)
}
}
func TestAitableDatasourceSyncRejectsEmptyTableIDs(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
"--table-ids", "")
if err == nil || !strings.Contains(err.Error(), "table-ids") {
t.Fatalf("error = %v, want table-ids error", err)
}
}
func TestAitableDatasourceSyncAcceptsBoundaryFive(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
"--table-ids", "T1,T2,T3,T4,T5")
if err != nil {
t.Fatalf("5 table-ids should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "run_datasource_sync" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
}
func TestAitableDatasourceSyncAcceptsSingleTableID(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
"--table-ids", "T1")
if err != nil {
t.Fatalf("single table-id should succeed: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(caller.calls))
}
}
func TestAitableDatasourceSyncStatusRejectsTooManyTaskIDs(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync-status",
"--base-id", "BASE123", "--table-id", "TBL456",
"--task-ids", "TK1,TK2,TK3,TK4,TK5,TK6")
if err == nil || !strings.Contains(err.Error(), "requires 1-5") {
t.Fatalf("error = %v, want 1-5 limit", err)
}
}
func TestAitableDatasourceSyncStatusAcceptsFiveTaskIDs(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "sync-status",
"--base-id", "BASE123", "--table-id", "TBL456",
"--task-ids", "TK1,TK2,TK3,TK4,TK5")
if err != nil {
t.Fatalf("5 task-ids should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_sync_status" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
}
func TestAitableDatasourceSyncStatusRequiresTaskIDs(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync-status",
"--base-id", "BASE123", "--table-id", "TBL456")
if err == nil || !strings.Contains(err.Error(), "task-ids") {
t.Fatalf("error = %v, want task-ids required", err)
}
}
func TestAitableDatasourceSyncStatusRejectsMissingTableID(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync-status", "--base-id", "BASE123")
if err == nil || !strings.Contains(err.Error(), "table-id") {
t.Fatalf("error = %v, want table-id required", err)
}
}
func TestAitableDatasourceGetConfigRejectsMissingTableID(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "get-config", "--base-id", "BASE123")
if err == nil || !strings.Contains(err.Error(), "table-id") {
t.Fatalf("error = %v, want table-id required", err)
}
}
func TestAitableDatasourceGetConfigSuccess(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "get-config",
"--base-id", "BASE123", "--table-id", "TBL456")
if err != nil {
t.Fatalf("get-config should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_config" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
if caller.calls[0].args["tableId"] != "TBL456" {
t.Fatalf("tableId = %v, want TBL456", caller.calls[0].args["tableId"])
}
}
func TestAitableDatasourceListSourcesSuccess(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "list-sources",
"--base-id", "BASE123", "--datasource-type", "OA")
if err != nil {
t.Fatalf("list-sources should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "list_datasource_sources" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
}
func TestAitableDatasourceListSourcesRejectsMissingType(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "list-sources", "--base-id", "BASE123")
if err == nil || !strings.Contains(err.Error(), "datasource-type") {
t.Fatalf("error = %v, want datasource-type required", err)
}
}
func TestAitableDatasourceGetFieldsRejectsMissingSourceConfig(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "get-fields",
"--base-id", "BASE123", "--datasource-type", "OA")
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config required", err)
}
}
func TestAitableDatasourceGetFieldsRejectsInvalidSourceConfig(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "get-fields",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `not-json`)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config validation error", err)
}
}
func TestAitableDatasourceGetFieldsRejectsNonObjectSourceConfig(t *testing.T) {
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
for _, raw := range cases {
_, err := runAitableDatasourceCommand(t, "get-fields",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", raw)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
}
}
}
func TestAitableDatasourceGetFieldsSuccess(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "get-fields",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
if err != nil {
t.Fatalf("get-fields should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_fields" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
if caller.calls[0].args["sourceConfig"] != `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}` {
t.Fatalf("sourceConfig not passed as raw string: %v", caller.calls[0].args["sourceConfig"])
}
}
func TestAitableDatasourceCreateRejectsMissingSourceConfig(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA")
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config required", err)
}
}
func TestAitableDatasourceCreateRejectsInvalidSourceConfig(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `not-json`)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config validation error", err)
}
}
func TestAitableDatasourceCreateRejectsNonObjectSourceConfig(t *testing.T) {
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
for _, raw := range cases {
_, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", raw)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
}
}
}
func TestAitableDatasourceCreateSuccess(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
if err != nil {
t.Fatalf("create should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "create_datasource" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
if v, ok := caller.calls[0].args["auto"]; !ok || v != false {
t.Fatalf("auto = %v, want false when not provided", v)
}
}
func TestAitableDatasourceCreateWithAuto(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
"--auto")
if err != nil {
t.Fatalf("create with --auto should succeed: %v", err)
}
if v, ok := caller.calls[0].args["auto"]; !ok || v != true {
t.Fatalf("auto = %v, want true", v)
}
}
func TestAitableDatasourceCreateWithFieldIDsAndAutoSyncSetting(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
"--auto",
"--field-ids", "fldAAA,fldBBB",
"--auto-sync-setting", `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`)
if err != nil {
t.Fatalf("create with field-ids and auto-sync-setting should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "create_datasource" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
fieldIDs, ok := caller.calls[0].args["fieldIds"].([]string)
if !ok || len(fieldIDs) != 2 || fieldIDs[0] != "fldAAA" || fieldIDs[1] != "fldBBB" {
t.Fatalf("fieldIds = %v, want [fldAAA fldBBB]", caller.calls[0].args["fieldIds"])
}
if caller.calls[0].args["autoSyncSetting"] != `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` {
t.Fatalf("autoSyncSetting not passed as raw string: %v", caller.calls[0].args["autoSyncSetting"])
}
}
func TestAitableDatasourceCreateRejectsInvalidAutoSyncSetting(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
"--auto-sync-setting", `not-json`)
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
t.Fatalf("error = %v, want auto-sync-setting validation error", err)
}
}
func TestAitableDatasourceUpdateRejectsMissingTableID(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "update", "--base-id", "BASE123")
if err == nil || !strings.Contains(err.Error(), "table-id") {
t.Fatalf("error = %v, want table-id required", err)
}
}
func TestAitableDatasourceUpdateRejectsInvalidSourceConfig(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--source-config", `not-json`)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config validation error", err)
}
}
func TestAitableDatasourceUpdateRejectsNonObjectSourceConfig(t *testing.T) {
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
for _, raw := range cases {
_, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--source-config", raw)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
}
}
}
func TestAitableDatasourceUpdateRejectsNoChanges(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456")
if err == nil || !strings.Contains(err.Error(), "至少需要一个配置变更") {
t.Fatalf("error = %v, want at least one config change required", err)
}
if len(caller.calls) != 0 {
t.Fatalf("MCP should not be called when no changes provided")
}
}
func TestAitableDatasourceUpdateWithAutoOnly(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456", "--auto")
if err != nil {
t.Fatalf("update with --auto only should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "update_datasource_config" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
if v, ok := caller.calls[0].args["auto"]; !ok || v != true {
t.Fatalf("auto = %v, want true", v)
}
}
func TestAitableDatasourceUpdateWithSourceConfig(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--source-config", `{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
if err != nil {
t.Fatalf("update with source-config should succeed: %v", err)
}
if caller.calls[0].args["sourceConfig"] != `{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}` {
t.Fatalf("sourceConfig not passed as raw string: %v", caller.calls[0].args["sourceConfig"])
}
if _, ok := caller.calls[0].args["auto"]; ok {
t.Fatalf("auto should not be sent when --auto is omitted")
}
}
func TestAitableDatasourceUpdateWithAutoFalse(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456", "--auto=false")
if err != nil {
t.Fatalf("update with --auto=false should succeed: %v", err)
}
if v, ok := caller.calls[0].args["auto"]; !ok || v != false {
t.Fatalf("auto = %v, want false", v)
}
}
func TestAitableDatasourceUpdateWithFieldIDsAndAutoSyncSetting(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--field-ids", "fldAAA,fldBBB",
"--auto-sync-setting", `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`)
if err != nil {
t.Fatalf("update with field-ids and auto-sync-setting should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "update_datasource_config" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
fieldIDs, ok := caller.calls[0].args["fieldIds"].([]string)
if !ok || len(fieldIDs) != 2 || fieldIDs[0] != "fldAAA" || fieldIDs[1] != "fldBBB" {
t.Fatalf("fieldIds = %v, want [fldAAA fldBBB]", caller.calls[0].args["fieldIds"])
}
if caller.calls[0].args["autoSyncSetting"] != `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` {
t.Fatalf("autoSyncSetting not passed as raw string: %v", caller.calls[0].args["autoSyncSetting"])
}
}
func TestAitableDatasourceUpdateRejectsInvalidAutoSyncSetting(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--auto-sync-setting", `not-json`)
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
t.Fatalf("error = %v, want auto-sync-setting validation error", err)
}
}
func TestAitableDatasourceUpdateRejectsNonObjectAutoSyncSetting(t *testing.T) {
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
for _, raw := range cases {
_, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--auto-sync-setting", raw)
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
t.Fatalf("auto-sync-setting %q: error = %v, want auto-sync-setting validation error", raw, err)
}
}
}
func TestAitableDatasourceUpdateRejectsEmptyExplicitFieldIDs(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--field-ids", "")
if err == nil || !strings.Contains(err.Error(), "field-ids") {
t.Fatalf("error = %v, want field-ids empty error", err)
}
if len(caller.calls) != 0 {
t.Fatalf("MCP should not be called when empty field-ids is rejected")
}
}
func TestAitableDatasourceUpdateRejectsEmptyExplicitAutoSyncSetting(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--auto-sync-setting", "")
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
t.Fatalf("error = %v, want auto-sync-setting empty error", err)
}
if len(caller.calls) != 0 {
t.Fatalf("MCP should not be called when empty auto-sync-setting is rejected")
}
}
func TestAitableDatasourceGetFieldsRejectsMissingBaseID(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "get-fields",
"--datasource-type", "OA",
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
if err == nil || !strings.Contains(err.Error(), "base-id") {
t.Fatalf("error = %v, want base-id required", err)
}
}
func TestAitableDatasourceCreateRejectsMissingBaseID(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "create",
"--datasource-type", "OA",
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
if err == nil || !strings.Contains(err.Error(), "base-id") {
t.Fatalf("error = %v, want base-id required", err)
}
}
@@ -23,27 +23,42 @@ type aitableWorkflowCall struct {
}
type aitableWorkflowCaller struct {
calls []aitableWorkflowCall
calls []aitableWorkflowCall
response string
err error
dryRun bool
}
func (c *aitableWorkflowCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, aitableWorkflowCall{productID: productID, toolName: toolName, args: args})
if c.err != nil {
return nil, c.err
}
response := c.response
if response == "" {
response = `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}`
}
return &edition.ToolResult{Content: []edition.ContentBlock{{
Type: "text",
Text: `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}`,
Text: response,
}}}, nil
}
func (*aitableWorkflowCaller) Format() string { return "json" }
func (*aitableWorkflowCaller) DryRun() bool { return false }
func (c *aitableWorkflowCaller) DryRun() bool { return c.dryRun }
func (*aitableWorkflowCaller) Fields() string { return "" }
func (*aitableWorkflowCaller) JQ() string { return "" }
func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*aitableWorkflowCaller, error) {
t.Helper()
caller := &aitableWorkflowCaller{}
return caller, runAitableWorkflowCommandWithCaller(t, caller, stdin, args...)
}
func runAitableWorkflowCommandWithCaller(t *testing.T, caller *aitableWorkflowCaller, stdin io.Reader, args ...string) error {
t.Helper()
testseam.Protect(t, &os.Args)
caller := &aitableWorkflowCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
os.Args = append([]string{"dws", "aitable", "workflow"}, args...)
@@ -51,6 +66,7 @@ func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*
cmd := newAitableCommand()
cmd.PersistentFlags().String("format", "json", "output format")
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
cmd.PersistentFlags().Bool("dry-run", false, "preview only")
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(append([]string{"workflow"}, args...))
@@ -58,10 +74,10 @@ func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*
stdin = strings.NewReader("")
}
cmd.SetIn(stdin)
return caller, cmd.Execute()
return cmd.Execute()
}
func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
func TestCrossPlatformCoverageAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
wantDSL := map[string]any{
"version": "workflow-dsl/v1",
"name": "create test",
@@ -92,6 +108,65 @@ func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
}
}
func TestCrossPlatformCoverageAitableWorkflowPublishRejectsFalseSuccess(t *testing.T) {
tests := []struct {
name string
response string
want string
}{
{name: "valid false", response: `{"status":"success","data":{"valid":false,"issues":[{"message":"bad dsl"}]}}`, want: "valid=false"},
{name: "missing valid", response: `{"status":"success","data":{"flowId":"flow-test"}}`, want: "missing valid"},
{name: "missing flow id", response: `{"status":"success","data":{"valid":true}}`, want: "missing a non-empty flowId"},
{name: "wrong valid type", response: `{"status":"success","data":{"valid":"true","flowId":"flow-test"}}`, want: "valid must be boolean"},
{name: "malformed response", response: `{`, want: "not a JSON object"},
{name: "null response", response: `null`, want: "not a JSON object"},
{name: "conflicting valid", response: `{"valid":true,"data":{"valid":false,"flowId":"flow-test"}}`, want: "conflicting valid values"},
{name: "invalid flow id", response: `{"valid":true,"flowId":1}`, want: "flowId must be a non-empty string"},
{name: "conflicting workflow ids", response: `{"valid":true,"flowId":"one","data":{"workflowId":"two"}}`, want: "conflicting workflow IDs"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &aitableWorkflowCaller{response: tc.response}
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
if err == nil || !strings.Contains(err.Error(), tc.want) || len(caller.calls) != 1 {
t.Fatalf("workflow false success = err:%v calls:%#v", err, caller.calls)
}
})
}
t.Run("update uses the same strict contract", func(t *testing.T) {
caller := &aitableWorkflowCaller{response: `{"status":"success","data":{"valid":false}}`}
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
"update", "--base-id", "base", "--workflow-id", "flow", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
if err == nil || !strings.Contains(err.Error(), "valid=false") || len(caller.calls) != 1 || caller.calls[0].toolName != "update_workflow" {
t.Fatalf("workflow update false success = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("dry-run does not call publish tool", func(t *testing.T) {
caller := &aitableWorkflowCaller{dryRun: true}
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`, "--dry-run")
if err != nil || len(caller.calls) != 0 {
t.Fatalf("workflow create dry-run = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("transport error", func(t *testing.T) {
caller := &aitableWorkflowCaller{err: context.Canceled}
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || len(caller.calls) != 1 {
t.Fatalf("workflow transport error = err:%v calls:%#v", err, caller.calls)
}
})
if _, _, _, err := strictAitableWorkflowPublishResult(nil); err == nil || !strings.Contains(err.Error(), "empty response") {
t.Fatalf("nil workflow envelope = %v", err)
}
}
func TestAitableWorkflowEditExampleMapsEmptyArguments(t *testing.T) {
caller, err := runAitableWorkflowCommand(t, nil, "edit-example")
if err != nil {
+1 -1
View File
@@ -10569,7 +10569,7 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
chatCategoryCmd.AddCommand(chatCategoryCreateSmartCmd)
chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageAddFavoriteCmd, chatMessageRemoveFavoriteCmd, chatMessageListFavoritesCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd, chatMessageListEmotionRepliesCmd)
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd, newChatToolbarCommand())
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd, newChatToolbarCommand(), newChatEmotionCommand())
// Keep the v1.0.56 command surface recognizable while directing callers to
// the supported nested commands. The chat root's "im" alias makes these
+253
View File
@@ -0,0 +1,253 @@
package helpers
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/spf13/cobra"
)
const personalEmotionUnpinnedReason = "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
func newChatEmotionCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "emotion",
Short: "个人收藏表情",
Long: "查询、发送和新增当前用户的个人收藏表情。",
RunE: groupRunE,
}
cmd.AddCommand(
newChatEmotionListCommand(),
newChatEmotionSendCommand(),
newChatEmotionFavoriteCommand(),
)
return cmd
}
func newChatEmotionListCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "列出个人收藏表情",
RunE: func(cmd *cobra.Command, args []string) error {
return callMCPToolOnServer("im", "list_personal_emotions", map[string]any{})
},
}
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat", Name: "list_personal_emotions",
CanonicalPath: "chat.list_personal_emotions", CLIPath: "chat emotion list", PrimaryCLIPath: "chat emotion list",
},
Description: "列出当前用户的个人收藏表情",
Interface: &contract.InterfaceSpec{
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
},
Selection: contract.SelectionSpec{
AgentSummary: "列出当前用户的个人收藏表情",
UseWhen: []string{"需要查看当前用户已收藏的表情、获取 emotionId 或 mediaId 时"},
AvoidWhen: []string{"查询消息 reaction 使用 chat message list-emotion-replies"},
Examples: []string{"dws chat emotion list --format json"},
},
},
})
return cmd
}
func newChatEmotionSendCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "send",
Short: "发送个人收藏表情",
Long: `发送当前用户的个人收藏表情。
⚠️ 重要:该接口会真实发送表情到目标会话,不可用于测试或试探性调用。调用前必须确认表情媒体 ID 和接收对象无误。`,
Example: ` dws chat emotion send --media-id <mediaId> --group <openConversationId>
dws chat emotion send --media-id <mediaId> --emotion-id <emotionId> --user <userId>
dws chat emotion send --media-id <mediaId> --open-dingtalk-id <openDingTalkId> --uuid <idempotencyKey>`,
RunE: func(cmd *cobra.Command, args []string) error {
mediaID, _ := cmd.Flags().GetString("media-id")
if strings.TrimSpace(mediaID) == "" {
return fmt.Errorf("--media-id is required")
}
target, err := personalEmotionSendTarget(cmd)
if err != nil {
return err
}
payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)}
emotionID, _ := cmd.Flags().GetString("emotion-id")
if strings.TrimSpace(emotionID) != "" {
payload["emotionId"] = strings.TrimSpace(emotionID)
}
for key, value := range target {
payload[key] = value
}
if uuid := strings.TrimSpace(flagOrFallback(cmd, "uuid", "idempotency-key")); uuid != "" {
payload["uuid"] = uuid
}
return callMCPToolOnServer("im", "send_personal_emotion", payload)
},
}
cmd.Flags().String("media-id", "", "表情媒体 ID (必填)")
cmd.Flags().String("emotion-id", "", "表情 ID")
cmd.Flags().String("conversation-id", "", "群聊 openConversationId")
cmd.Flags().String("group", "", "群聊 openConversationId(--conversation-id 别名)")
cmd.Flags().String("user", "", "单聊接收人 userId;CLI 会解析为 openDingTalkId")
cmd.Flags().String("open-dingtalk-id", "", "单聊接收人 openDingTalkId")
cmd.Flags().String("uuid", "", "幂等键")
cmd.Flags().String("idempotency-key", "", "幂等键(--uuid 别名)")
cmd.MarkFlagsMutuallyExclusive("conversation-id", "group")
cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}},
RequireOneOf: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}},
})
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
},
Contract: personalEmotionSendContract(),
})
return cmd
}
func newChatEmotionFavoriteCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "favorite",
Short: "新增个人收藏表情",
Example: ` dws chat emotion favorite --media-id <mediaId> --name "赞"
dws chat emotion favorite --media-id <mediaId> --source-conversation-id <cid> --source-message-id <mid>`,
RunE: func(cmd *cobra.Command, args []string) error {
mediaID, _ := cmd.Flags().GetString("media-id")
if strings.TrimSpace(mediaID) == "" {
return fmt.Errorf("--media-id is required")
}
sourceConversationID, _ := cmd.Flags().GetString("source-conversation-id")
sourceMessageID, _ := cmd.Flags().GetString("source-message-id")
if err := validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID); err != nil {
return err
}
payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)}
name, _ := cmd.Flags().GetString("name")
if strings.TrimSpace(name) != "" {
payload["name"] = strings.TrimSpace(name)
}
if strings.TrimSpace(sourceConversationID) != "" {
payload["sourceConversationId"] = strings.TrimSpace(sourceConversationID)
payload["sourceMessageId"] = strings.TrimSpace(sourceMessageID)
}
return callMCPToolOnServer("im", "favorite_personal_emotion", payload)
},
}
cmd.Flags().String("media-id", "", "待收藏 mediaId (必填)")
cmd.Flags().String("name", "", "表情名称")
cmd.Flags().String("source-conversation-id", "", "来源会话 ID;需与 --source-message-id 成对指定")
cmd.Flags().String("source-message-id", "", "来源消息 ID;需与 --source-conversation-id 成对指定")
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
},
Contract: personalEmotionFavoriteContract(),
})
return cmd
}
func personalEmotionSendTarget(cmd *cobra.Command) (map[string]any, error) {
groupID := strings.TrimSpace(flagOrFallback(cmd, "conversation-id", "group"))
userID, _ := cmd.Flags().GetString("user")
openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id")
userID = strings.TrimSpace(userID)
openDingTalkID = strings.TrimSpace(openDingTalkID)
specified := 0
for _, value := range []string{groupID, userID, openDingTalkID} {
if value != "" {
specified++
}
}
if specified != 1 {
return nil, fmt.Errorf("--conversation-id, --user or --open-dingtalk-id is required; specify exactly one")
}
if groupID != "" {
return map[string]any{"openConversationId": groupID}, nil
}
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return nil, err
}
return map[string]any{"receiverOpenDingTalkId": openDingTalkID}, nil
}
if isOpenDingTalkID(userID) {
return map[string]any{"receiverOpenDingTalkId": userID}, nil
}
resolved, err := resolveOpenDingTalkID(cmd.Context(), userID)
if err != nil {
return nil, fmt.Errorf("cannot resolve --user %q to openDingTalkId: %w; pass --open-dingtalk-id instead", userID, err)
}
return map[string]any{"receiverOpenDingTalkId": resolved}, nil
}
func validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID string) error {
hasConversation := strings.TrimSpace(sourceConversationID) != ""
hasMessage := strings.TrimSpace(sourceMessageID) != ""
if hasConversation != hasMessage {
return fmt.Errorf("--source-conversation-id and --source-message-id must be specified together")
}
return nil
}
func personalEmotionSendContract() LeafContract {
return LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat", Name: "send_personal_emotion",
CanonicalPath: "chat.send_personal_emotion", CLIPath: "chat emotion send", PrimaryCLIPath: "chat emotion send",
},
Description: "以当前用户身份向群聊或单聊发送个人收藏表情",
Interface: &contract.InterfaceSpec{
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
},
Selection: contract.SelectionSpec{
AgentSummary: "以当前用户身份发送个人收藏表情",
UseWhen: []string{"用户明确要求发送个人收藏表情,且已提供 mediaId 或 emotionId 时"},
AvoidWhen: []string{"发送普通文本、Markdown 或文件时使用 chat message send"},
Examples: []string{"dws chat emotion send --media-id <mediaId> --group <openConversationId> --uuid <idempotencyKey>"},
},
Parameters: []contract.ParamDecl{
{Name: "media-id", Property: "mediaId", Required: boolPtr(true)},
{Name: "emotion-id", Property: "emotionId", Required: boolPtr(false)},
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)},
{Name: "group", Property: "openConversationId", Required: boolPtr(false)},
{Name: "user", Property: "receiverOpenDingTalkId", Required: boolPtr(false)},
{Name: "open-dingtalk-id", Property: "receiverOpenDingTalkId", Required: boolPtr(false)},
{Name: "uuid", Property: "uuid", Required: boolPtr(false)},
{Name: "idempotency-key", Property: "uuid", Required: boolPtr(false)},
},
}
}
func personalEmotionFavoriteContract() LeafContract {
return LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat", Name: "favorite_personal_emotion",
CanonicalPath: "chat.favorite_personal_emotion", CLIPath: "chat emotion favorite", PrimaryCLIPath: "chat emotion favorite",
},
Description: "将 mediaId 新增到当前用户的个人收藏表情",
Interface: &contract.InterfaceSpec{
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
},
Selection: contract.SelectionSpec{
AgentSummary: "新增当前用户的个人收藏表情",
UseWhen: []string{"用户要把一个 mediaId 收藏为个人表情时"},
AvoidWhen: []string{"收藏消息使用 chat message add-favorite"},
Examples: []string{"dws chat emotion favorite --media-id <mediaId> --name \"赞\""},
},
Parameters: []contract.ParamDecl{
{Name: "media-id", Property: "mediaId", Required: boolPtr(true)},
{Name: "name", Property: "name", Required: boolPtr(false)},
{Name: "source-conversation-id", Property: "sourceConversationId", Required: boolPtr(false), RequiredWhen: "source-message-id is provided"},
{Name: "source-message-id", Property: "sourceMessageId", Required: boolPtr(false), RequiredWhen: "source-conversation-id is provided"},
},
}
}
@@ -0,0 +1,254 @@
package helpers
import (
"context"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type personalEmotionCall struct {
server string
tool string
args map[string]any
}
type personalEmotionCaller struct {
calls []personalEmotionCall
}
func (c *personalEmotionCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for key, value := range args {
copied[key] = value
}
c.calls = append(c.calls, personalEmotionCall{server: server, tool: tool, args: copied})
if server == "contact" && tool == "get_user_info_by_user_ids" {
return textToolResult(`{"result":[{"userId":"u1","openDingTalkId":"` + helperCurrentDOpenID2 + `"}]}`), nil
}
return textToolResult(`{"ok":true}`), nil
}
func (*personalEmotionCaller) Format() string { return "json" }
func (*personalEmotionCaller) DryRun() bool { return false }
func (*personalEmotionCaller) Fields() string { return "" }
func (*personalEmotionCaller) JQ() string { return "" }
func executePersonalEmotionCommand(t *testing.T, caller *personalEmotionCaller, args ...string) error {
t.Helper()
installHelpersCoreDeps(t, caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
root := newChatCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.ExecuteContext(context.Background())
}
func requirePersonalEmotionCall(t *testing.T, caller *personalEmotionCaller, tool string, want map[string]any) {
t.Helper()
if len(caller.calls) != 1 {
t.Fatalf("calls = %d, want 1: %+v", len(caller.calls), caller.calls)
}
call := caller.calls[0]
if call.server != "im" || call.tool != tool {
t.Fatalf("tool call = %s/%s, want im/%s", call.server, call.tool, tool)
}
if !reflect.DeepEqual(call.args, want) {
t.Fatalf("args = %#v, want %#v", call.args, want)
}
}
func TestChatEmotionListCallsIMToolWithoutBusinessArgs(t *testing.T) {
// TC-001: list 无业务参数,当前用户身份由 MCP server 注入。
caller := &personalEmotionCaller{}
if err := executePersonalEmotionCommand(t, caller, "emotion", "list"); err != nil {
t.Fatalf("chat emotion list returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "list_personal_emotions", map[string]any{})
}
func TestChatEmotionSendMapsGroupTargetAndIdempotency(t *testing.T) {
// TC-002: 群聊目标映射为 openConversationId,uuid 与表情字段按 MCP 字段透传。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "send",
"--media-id", "@media",
"--emotion-id", "emotion123",
"--group", "cid123",
"--idempotency-key", "idem-001",
)
if err != nil {
t.Fatalf("chat emotion send returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
"mediaId": "@media",
"emotionId": "emotion123",
"openConversationId": "cid123",
"uuid": "idem-001",
})
}
func TestChatEmotionSendMapsOpenDingTalkTarget(t *testing.T) {
// TC-003: 已知 openDingTalkId 时直传 receiverOpenDingTalkId,不做外部解析。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "send",
"--media-id", "@media",
"--open-dingtalk-id", helperCurrentDOpenID,
)
if err != nil {
t.Fatalf("chat emotion send returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
"mediaId": "@media",
"receiverOpenDingTalkId": helperCurrentDOpenID,
})
}
func TestChatEmotionSendTreatsOpenDingTalkIDPassedAsUserAsResolvedTarget(t *testing.T) {
// TC-004: --user 收到 openDingTalkId 形态时保持 chat message send 的兼容语义。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "send",
"--media-id", "@media",
"--user", helperCurrentDOpenID,
)
if err != nil {
t.Fatalf("chat emotion send returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
"mediaId": "@media",
"receiverOpenDingTalkId": helperCurrentDOpenID,
})
}
func TestChatEmotionSendResolvesUserIDTarget(t *testing.T) {
// TC-004b: --user 收到普通 userId 时先解析为 openDingTalkId,再发送个人收藏表情。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "send",
"--media-id", "@media",
"--user", "u1",
)
if err != nil {
t.Fatalf("chat emotion send returned error: %v", err)
}
if len(caller.calls) != 2 {
t.Fatalf("calls = %d, want contact resolve then send: %+v", len(caller.calls), caller.calls)
}
resolveCall := caller.calls[0]
if resolveCall.server != "contact" || resolveCall.tool != "get_user_info_by_user_ids" {
t.Fatalf("resolve call = %s/%s, want contact/get_user_info_by_user_ids", resolveCall.server, resolveCall.tool)
}
sendCall := caller.calls[1]
if sendCall.server != "im" || sendCall.tool != "send_personal_emotion" {
t.Fatalf("send call = %s/%s, want im/send_personal_emotion", sendCall.server, sendCall.tool)
}
want := map[string]any{
"mediaId": "@media",
"receiverOpenDingTalkId": helperCurrentDOpenID2,
}
if !reflect.DeepEqual(sendCall.args, want) {
t.Fatalf("send args = %#v, want %#v", sendCall.args, want)
}
}
func TestChatEmotionSendRejectsInvalidTargets(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "missing target",
args: []string{"emotion", "send", "--media-id", "@media"},
wantErr: "specify exactly one",
},
{
name: "multiple targets",
args: []string{"emotion", "send", "--media-id", "@media", "--group", "cid", "--open-dingtalk-id", helperCurrentDOpenID},
wantErr: "specify exactly one",
},
{
name: "missing media",
args: []string{"emotion", "send", "--group", "cid"},
wantErr: "--media-id is required",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller, tc.args...)
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid command reached MCP: %+v", caller.calls)
}
})
}
}
func TestChatEmotionFavoriteMapsOptionalSourcePair(t *testing.T) {
// TC-005: 收藏来源字段成对出现时透传为 sourceConversationId/sourceMessageId。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "favorite",
"--media-id", "@media",
"--name", "赞",
"--source-conversation-id", "cid123",
"--source-message-id", "msg123",
)
if err != nil {
t.Fatalf("chat emotion favorite returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "favorite_personal_emotion", map[string]any{
"mediaId": "@media",
"name": "赞",
"sourceConversationId": "cid123",
"sourceMessageId": "msg123",
})
}
func TestChatEmotionFavoriteRejectsMissingRequiredOrUnpairedSource(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "missing media",
args: []string{"emotion", "favorite", "--name", "赞"},
wantErr: "--media-id is required",
},
{
name: "source conversation only",
args: []string{"emotion", "favorite", "--media-id", "@media", "--source-conversation-id", "cid123"},
wantErr: "must be specified together",
},
{
name: "source message only",
args: []string{"emotion", "favorite", "--media-id", "@media", "--source-message-id", "msg123"},
wantErr: "must be specified together",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller, tc.args...)
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid command reached MCP: %+v", caller.calls)
}
})
}
}
+4
View File
@@ -18,6 +18,7 @@ import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
@@ -231,6 +232,9 @@ Unicode Emoji。例如用户要求 😄 时传“憨笑”,要求 👏 时传
if err := validateRequiredFlags(cmd, "comment-key", "reaction"); err != nil {
return err
}
if err := commentreaction.Validate(mustGetFlag(cmd, "reaction")); err != nil {
return err
}
return callMCPToolOnServer(commentServer, "reply_comment", map[string]any{
"nodeId": nodeID,
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
+23
View File
@@ -135,6 +135,29 @@ func TestCrossPlatformCoverageCommentReactReplyForcesEmojiTrue(t *testing.T) {
}
}
func TestCrossPlatformCoverageCommentReactionsRejectUnsupportedValuesBeforeRPC(t *testing.T) {
for _, surface := range []string{"doc", "sheet"} {
for _, test := range []struct {
name string
args []string
}{
{name: "react unicode", args: []string{"comment", "react-reply", "--node", "node-1", "--comment-key", "comment-1", "--reaction", "😄"}},
{name: "react garbage", args: []string{"comment", "react-reply", "--node", "node-1", "--comment-key", "comment-1", "--reaction", "乱码"}},
{name: "reply unicode", args: []string{"comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "👏", "--emoji"}},
} {
t.Run(surface+"/"+test.name, func(t *testing.T) {
caller := &docCommentMutationCaller{}
if err := executeCommentBaseCommand(t, caller, surface, test.args...); err == nil {
t.Fatal("unsupported reaction accepted")
}
if len(caller.calls) != 0 {
t.Fatalf("unsupported reaction reached RPC: %#v", caller.calls)
}
})
}
}
}
func TestCrossPlatformCoverageCommentReactReplyGuidesDingTalkEmojiNames(t *testing.T) {
for _, surface := range []struct {
name string
@@ -10,6 +10,8 @@ import (
"runtime"
"sync"
"testing"
"github.com/spf13/cobra"
)
const (
@@ -39,7 +41,16 @@ func TestMain(m *testing.M) {
if os.Getenv(helpersShellStubEnv) == "1" {
os.Exit(runHelpersShellStub())
}
// Cobra's Windows pre-exec hook walks the process table on every Execute*
// call to detect Explorer launches. Helpers tests execute command trees
// thousands of times, and none of those in-process invocations can be an
// Explorer launch, so keep that production-only check out of the test
// process. This also prevents every new exhaustive harness from having to
// remember a test-local override.
originalMousetrapHelpText := cobra.MousetrapHelpText
cobra.MousetrapHelpText = ""
code := m.Run()
cobra.MousetrapHelpText = originalMousetrapHelpText
if helpersShellStubBaseDir != "" {
_ = os.RemoveAll(helpersShellStubBaseDir)
}
@@ -504,3 +504,147 @@ func TestSheetConfirmationGuardCoversEveryProtectedLeaf(t *testing.T) {
})
}
}
// executeGuardedPermissionRemoveCommand mirrors executeGuardedMutationCommand
// but additionally rewrites os.Args so resolveProductID routes doc/wiki
// auto-routed tools to their real MCP server (doc → doc, wiki → wiki).
func executeGuardedPermissionRemoveCommand(t *testing.T, caller *guardedMutationCaller, build func() *cobra.Command, args ...string) error {
t.Helper()
testseam.Protect(t, &os.Args)
root := build()
os.Args = append([]string{"dws", root.Name()}, args...)
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
if root.PersistentFlags().Lookup("yes") == nil {
root.PersistentFlags().Bool("yes", false, "confirm high-risk operation")
}
if root.PersistentFlags().Lookup("dry-run") == nil {
root.PersistentFlags().Bool("dry-run", false, "preview without executing")
}
root.SilenceErrors = true
root.SilenceUsage = true
if root.InOrStdin() == os.Stdin {
root.SetIn(strings.NewReader(""))
}
root.SetArgs(args)
return root.Execute()
}
// TestPermissionMemberRemoveRequiresConfirmationBeforeToolCall pins the
// destructive confirmation gate on the three batch remove entry points
// (drive/doc permission remove, wiki member remove). Removing up to 30
// USER/DEPT/CONVERSATION/TAG members in one call can revoke access for whole
// departments, chats, or role groups, so every invocation — new --members
// format and legacy --users alike — must confirm first: without --yes the
// command fails with the typed confirmation_required error and performs zero
// MCP calls; with --yes it dispatches exactly one call with the complete,
// precise tool arguments; --dry-run previews without any call.
func TestPermissionMemberRemoveRequiresConfirmationBeforeToolCall(t *testing.T) {
cases := []struct {
name string
build func() *cobra.Command
args []string
product string
tool string
wantArgs map[string]any
}{
{
name: "drive permission remove --members",
build: newDriveCommand,
args: []string{"permission", "remove", "--node", "n1", "--members", `[{"type":"CONVERSATION","id":"cid1"},{"type":"TAG","id":"t1","corpId":"c1"}]`},
product: "doc",
tool: "remove_permission",
wantArgs: map[string]any{
"nodeId": "n1",
"members": []map[string]any{
{"type": "CONVERSATION", "id": "cid1"},
{"type": "TAG", "id": "t1", "corpId": "c1"},
},
},
},
{
name: "drive permission remove --users",
build: newDriveCommand,
args: []string{"permission", "remove", "--node", "n1", "--users", "uid1,uid2"},
product: "doc",
tool: "remove_permission",
wantArgs: map[string]any{
"nodeId": "n1",
"userIds": []string{"uid1", "uid2"},
},
},
{
name: "doc permission remove --members",
build: newDocCommand,
args: []string{"permission", "remove", "--node", "n1", "--members", `[{"type":"USER","id":"u1","corpId":"c1"}]`},
product: "doc",
tool: "remove_permission",
wantArgs: map[string]any{
"nodeId": "n1",
"members": []map[string]any{{"type": "USER", "id": "u1", "corpId": "c1"}},
},
},
{
name: "wiki member remove --members",
build: newWikiCommand,
args: []string{"member", "remove", "--workspace", "ws1", "--members", `[{"type":"DEPT","id":"d1","corpId":"c1"}]`},
product: "wiki",
tool: "remove_member",
wantArgs: map[string]any{
"workspaceId": "ws1",
"members": []map[string]any{{"type": "DEPT", "id": "d1", "corpId": "c1"}},
},
},
{
name: "wiki member remove --users",
build: newWikiCommand,
args: []string{"member", "remove", "--workspace", "ws1", "--users", "uid1"},
product: "wiki",
tool: "remove_member",
wantArgs: map[string]any{
"workspaceId": "ws1",
"userIds": []string{"uid1"},
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
// 1) 未确认:typed confirmation_required 错误 + 零 MCP 调用。
caller := &guardedMutationCaller{}
err := executeGuardedPermissionRemoveCommand(t, caller, tc.build, tc.args...)
requireTypedConfirmationError(t, err)
if len(caller.calls) != 0 {
t.Fatalf("tool calls before confirmation = %#v, want none", caller.calls)
}
// 2) 明确确认(--yes):恰好一次调用,参数完整且精确。
confirmed := &guardedMutationCaller{}
if err := executeGuardedPermissionRemoveCommand(t, confirmed, tc.build, append(append([]string(nil), tc.args...), "--yes")...); err != nil {
t.Fatalf("confirmed remove returned error: %v", err)
}
if len(confirmed.calls) != 1 {
t.Fatalf("tool calls = %d, want exactly 1: %+v", len(confirmed.calls), confirmed.calls)
}
call := confirmed.calls[0]
if call.productID != tc.product || call.toolName != tc.tool {
t.Fatalf("tool call = %s/%s, want %s/%s", call.productID, call.toolName, tc.product, tc.tool)
}
if !reflect.DeepEqual(call.args, tc.wantArgs) {
t.Fatalf("tool args = %#v, want %#v", call.args, tc.wantArgs)
}
// 3) --dry-run 预览:不触发任何 MCP 调用。
dryRun := &guardedMutationCaller{dryRun: true}
if err := executeGuardedPermissionRemoveCommand(t, dryRun, tc.build, append(append([]string(nil), tc.args...), "--dry-run")...); err != nil {
t.Fatalf("dry-run remove returned error: %v", err)
}
if len(dryRun.calls) != 0 {
t.Fatalf("dry-run tool calls = %#v, want none", dryRun.calls)
}
})
}
}
+20 -2
View File
@@ -17,6 +17,16 @@ import (
// remindType: 服务端 API 1=应用内 2=短信 3=电话
var dingRemindTypeMap = map[string]int{"app": 1, "sms": 2, "call": 3}
var dingPersonalRemindTypeMap = map[string]string{"app": "APP", "sms": "SMS", "call": "PHONE"}
func dingPersonalRemindType(value string) (string, error) {
remindType, ok := dingPersonalRemindTypeMap[strings.ToLower(strings.TrimSpace(value))]
if !ok {
return "", fmt.Errorf("--type must be one of app, sms, call")
}
return remindType, nil
}
func newDingCommand() *cobra.Command {
// Product-level Agent routing Decl (migrated from selection/ding.json
// products.ding). Catalog assembly stamps provenance contract_final.
@@ -232,11 +242,15 @@ func newDingCommand() *cobra.Command {
if err := validateRequiredFlags(cmd, "users", "content"); err != nil {
return err
}
remindType, err := dingPersonalRemindType(mustGetFlag(cmd, "type"))
if err != nil {
return err
}
users := parseCSVValues(mustGetFlag(cmd, "users"))
toolArgs := map[string]any{
"receiverOpenDingTalkIds": users,
"content": mustGetFlag(cmd, "content"),
"remindType": mustGetFlag(cmd, "type"),
"remindType": remindType,
}
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
toolArgs["uuid"] = v
@@ -262,12 +276,16 @@ func newDingCommand() *cobra.Command {
if err := validateRequiredFlags(cmd, "group", "message-id", "users"); err != nil {
return err
}
remindType, err := dingPersonalRemindType(mustGetFlag(cmd, "type"))
if err != nil {
return err
}
users := parseCSVValues(mustGetFlag(cmd, "users"))
toolArgs := map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"openMessageId": mustGetFlag(cmd, "message-id"),
"receiverOpenDingTalkIds": users,
"remindType": mustGetFlag(cmd, "type"),
"remindType": remindType,
}
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
toolArgs["uuid"] = v
@@ -0,0 +1,18 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package helpers
import "testing"
func TestCrossPlatformCoverageDingPersonalRemindTypeMatchesMCPEnum(t *testing.T) {
for input, want := range map[string]string{"app": "APP", "sms": "SMS", "call": "PHONE", " APP ": "APP"} {
got, err := dingPersonalRemindType(input)
if err != nil || got != want {
t.Errorf("dingPersonalRemindType(%q)=(%q,%v), want %q", input, got, err, want)
}
}
if got, err := dingPersonalRemindType("push"); err == nil || got != "" {
t.Fatalf("unsupported remind type=(%q,%v)", got, err)
}
}
+277 -59
View File
@@ -15,6 +15,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
@@ -1428,9 +1429,14 @@ func newDocCommand() *cobra.Command {
readCmd := &cobra.Command{
Use: "read",
Short: "读取文档内容 (Markdown)",
Long: `获取文档内容,以 Markdown 格式返回。支持传入文档 URL 或 ID。`,
Long: `获取文档内容,以 Markdown 格式返回。支持传入文档 URL 或 ID。
互联网公开文档(含开启密码保护的)可传入公开链接;设置了访问密码时通过 --password 提供。
--version 读取指定历史版本内容(版本号从 dws doc version list 获取,0 表示文档初始版本,需要文档编辑权限);缺省读最新版。`,
Example: ` dws doc read --node DOC_ID
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"`,
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
dws doc read --node PUBLIC_URL --password <ACCESS_PASSWORD>
dws doc read --node DOC_ID --version 3`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
@@ -1440,6 +1446,15 @@ func newDocCommand() *cobra.Command {
"dws doc read --node DOC_ID --content-format jsonml"); err != nil {
return err
}
password, _ := cmd.Flags().GetString("password")
historyVersion := 0
historyVersionSet := cmd.Flags().Changed("version")
if historyVersionSet {
historyVersion, _ = cmd.Flags().GetInt("version")
if historyVersion < 0 {
return fmt.Errorf("--version 必须为非负整数历史版本号(0 表示初始版本,版本号从 dws doc version list 获取),当前值: %d", historyVersion)
}
}
format, _ := cmd.Flags().GetString("content-format")
scope, _ := cmd.Flags().GetString("scope")
tags, _ := cmd.Flags().GetString("tags")
@@ -1480,15 +1495,18 @@ func newDocCommand() *cobra.Command {
startBlockID,
endBlockID,
outputPath,
password,
historyVersion,
historyVersionSet,
)
}
if format == "jsonml" {
outputPath, _ := cmd.Flags().GetString("output")
return runDocReadJsonML(cmd, nodeID, outputPath)
return runDocReadJsonML(nodeID, outputPath, password, historyVersion, historyVersionSet)
}
return callMCPTool("get_document_content", map[string]any{
"nodeId": nodeID,
})
toolArgs := map[string]any{"nodeId": nodeID}
applyDocReadAccessParams(toolArgs, password, historyVersion, historyVersionSet)
return callMCPTool("get_document_content", toolArgs)
},
}
DeclareLeafMetadata(readCmd, LeafSpec{
@@ -1515,6 +1533,8 @@ func newDocCommand() *cobra.Command {
UseWhen: []string{
"用户要读取钉钉在线文字文档(adoc)正文(Markdown)时",
"用户直接粘贴文档 URL 且无其他指令时(默认读内容)",
"互联网公开文档(含设置密码保护的公开链接)时配合 --password 提供访问密码",
"要读取指定历史版本内容时用 --version(版本号来自 doc version list,0 表示初始版本,需要编辑权限)",
"只需标题大纲、指定块区间/单块或特定 JSONML tags 时使用 --content-format jsonml 与 --scope",
},
AvoidWhen: []string{
@@ -1532,9 +1552,11 @@ func newDocCommand() *cobra.Command {
{Name: "content-format", Property: "format", Required: boolPtr(false)},
{Name: "end-block-id", Required: boolPtr(false)},
{Name: "max-depth", Required: boolPtr(false), InterfaceType: "integer"},
{Name: "password", Property: "password", Required: boolPtr(false)},
{Name: "scope", Required: boolPtr(false)},
{Name: "start-block-id", Required: boolPtr(false), RequiredWhen: "--scope=range or --scope=section"},
{Name: "tags", Required: boolPtr(false), RequiredWhen: "--scope=tags"},
{Name: "version", Property: "historyVersion", Required: boolPtr(false), InterfaceType: "integer"},
},
},
})
@@ -2679,6 +2701,8 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
readCmd.Flags().Int("max-depth", 0, "筛选遍历最大深度, 0 表示不限(仅 --scope 时生效)")
readCmd.Flags().String("start-block-id", "", "range/section 起始块 ID(节点 uuid); scope=range/section 时必填")
readCmd.Flags().String("end-block-id", "", "range 结束块 ID(节点 uuid); \"-1\"或空=到文档末尾(仅 scope=range 生效)")
readCmd.Flags().String("password", "", "互联网公开文档开启密码保护时的访问密码;普通文档无需传入")
readCmd.Flags().Int("version", 0, "读取指定历史版本内容(版本号从 doc version list 获取, 0 表示初始版本, 需要文档编辑权限);缺省读最新版")
cli.AnnotateRuntimeFlagEnum(readCmd, "scope", "outline", "range", "section", "tags")
cli.AnnotateRuntimeFlagRequiredWhen(readCmd, "tags", "--scope=tags")
cli.AnnotateRuntimeFlagRequiredWhen(readCmd, "start-block-id", "--scope=range or --scope=section")
@@ -3220,6 +3244,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
}
if v, _ := cmd.Flags().GetBool("emoji"); v {
if err := commentreaction.Validate(mustGetFlag(cmd, "content")); err != nil {
return err
}
groupMentions, err := commentGroupMentionIDs(cmd)
if err != nil {
return err
@@ -3529,11 +3556,20 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
permissionAddCmd := &cobra.Command{
Use: "add",
Short: "添加文档协作者",
Args: cobra.NoArgs,
Long: `为指定文档(或文件夹/文件)添加一个或多个协作成员,并授予指定角色。
通过 --user 传入逗号分隔的 userId 列表,多个用户将被授予同一角色。
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
支持的角色 (--role)(必须大写):
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织)
DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织)
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型
支持的角色(大小写不敏感):
MANAGER 管理员,可读写、管理成员
EDITOR 编辑者,可查看、编辑、上传内容
DOWNLOADER 查看下载者,可查看并下载内容
@@ -3541,29 +3577,46 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
注意:
- OWNER 角色不可通过此接口添加。
- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
- 单次请求最多 30 个成员,超出请分批调用。
- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false;省略时 CLI 不向服务端发送该字段,服务端按不通知处理,需要通知请显式传 --notify。
用户 uid 可通过「钉钉通讯录」相关命令检索,如:
dws contact user search --keyword "姓名"`,
Example: ` dws doc permission add --node DOC_ID --users uid1 --role READER
dws doc permission add --node DOC_ID --users uid1,uid2,uid3 --role EDITOR
dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1 --role MANAGER --workspace WS_ID`,
dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1 --role MANAGER --workspace WS_ID
dws doc permission add --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"},{"type":"DEPT","id":"deptId1","roleId":"EDITOR","corpId":"xxx"}]' --notify
dws doc permission add --node DOC_ID --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "role"); err != nil {
if err := validateMembersExclusivity(cmd); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, false)
if mErr != nil {
return mErr
}
toolArgs := map[string]any{
"nodeId": nodeID,
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
"userIds": userIds,
if len(members) > 0 {
toolArgs["members"] = members
if cmd.Flags().Changed("notify") {
notify, _ := cmd.Flags().GetBool("notify")
toolArgs["notify"] = notify
}
} else {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
toolArgs["userIds"] = userIds
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
@@ -3600,7 +3653,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
Examples: []string{"dws doc permission add --node <DOC_ID> --users uid1 --role READER --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "notify", Property: "notify"},
{Name: "role", Property: "roleId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
@@ -3609,18 +3664,31 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
})
permissionAddCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
permissionAddCmd.Flags().String("users", "", "被授权的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
permissionAddCmd.Flags().String("users", "", "被授权的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
permissionAddCmd.Flags().String("user", "", "")
_ = permissionAddCmd.Flags().MarkHidden("user")
permissionAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
permissionAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)")
permissionAddCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
permissionAddCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
permissionAddCmd.Flags().Bool("notify", false, "是否通知被添加的成员(仅 --members 新格式时生效,需显式传入才通知)")
permissionUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新文档协作者权限",
Long: `更新指定节点已有协作者的权限角色(仅支持 USER 类型成员)。
Args: cobra.NoArgs,
Long: `更新指定节点已有协作者的权限角色。
支持的角色 (--role)(必须大写):
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId
DEPT 部门,id 为部门 ID,需携带 corpId
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
支持的角色 (--role)(大小写不敏感):
MANAGER 管理员
EDITOR 编辑者
DOWNLOADER 查看下载者
@@ -3630,26 +3698,43 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
- OWNER 角色不可通过此接口变更。
- 同一成员在同一节点只能拥有一个角色,变更后旧角色自动替换。
- 若成员的角色来自父节点的权限继承(PASS_ON),且继承角色高于目标角色,接口会拒绝操作。
- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知,默认 false。
仅可更新已存在协作关系的用户,新增协作者请使用 dws doc permission add。`,
Example: ` dws doc permission update --node DOC_ID --users uid1 --role EDITOR
dws doc permission update --node DOC_ID --users uid1,uid2 --role READER`,
dws doc permission update --node DOC_ID --users uid1,uid2 --role READER
dws doc permission update --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
dws doc permission update --node DOC_ID --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "role"); err != nil {
if err := validateMembersExclusivity(cmd); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, false)
if mErr != nil {
return mErr
}
toolArgs := map[string]any{
"nodeId": nodeID,
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
"userIds": userIds,
if len(members) > 0 {
toolArgs["members"] = members
if cmd.Flags().Changed("notify") {
notify, _ := cmd.Flags().GetBool("notify")
toolArgs["notify"] = notify
}
} else {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
toolArgs["userIds"] = userIds
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
@@ -3683,7 +3768,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
Examples: []string{"dws doc permission update --node <DOC_ID> --users uid1 --role EDITOR --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "notify", Property: "notify"},
{Name: "role", Property: "roleId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
@@ -3692,11 +3779,13 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
})
permissionUpdateCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
permissionUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
permissionUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
permissionUpdateCmd.Flags().String("user", "", "")
_ = permissionUpdateCmd.Flags().MarkHidden("user")
permissionUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
permissionUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)")
permissionUpdateCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
permissionUpdateCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
permissionUpdateCmd.Flags().Bool("notify", false, "是否通知被变更的成员(仅 --members 新格式时生效)")
permissionListCmd := &cobra.Command{
Use: "list",
@@ -3704,11 +3793,14 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
Short: "查询文档协作者列表",
Long: `查询指定节点的协作者列表,返回每位成员的 userId、姓名、角色等信息。
注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 200)。
若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围。`,
底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。
出参包含 totalCount(全量成员总数)、hasMore(是否还有下一页)和 nextToken(下一页游标)。
当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。
操作者需满足该节点配置的权限管理最低角色要求,权限不足返回 forbidden.accessDenied。`,
Example: ` dws doc permission list --node DOC_ID
dws doc permission list --node DOC_ID --limit 100
dws doc permission list --node DOC_ID --filter-role MANAGER,EDITOR`,
dws doc permission list --node DOC_ID --limit 50
dws doc permission list --node DOC_ID --filter-role MANAGER,EDITOR
dws doc permission list --node DOC_ID --next-token <上次返回的 nextToken>`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
@@ -3717,14 +3809,13 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
toolArgs := map[string]any{
"nodeId": nodeID,
}
limit := 0
if cmd.Flags().Changed("limit") {
limit, _ = cmd.Flags().GetInt("limit")
} else if cmd.Flags().Changed("max-results") {
limit, _ = cmd.Flags().GetInt("max-results")
if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil {
return err
} else if ok {
toolArgs["pageSize"] = size
}
if limit > 0 {
toolArgs["maxResults"] = limit
if v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" {
toolArgs["nextToken"] = v
}
if v := mustGetFlag(cmd, "filter-role"); v != "" {
toolArgs["filterRoleIds"] = parseRoleList(v)
@@ -3762,50 +3853,75 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
},
Parameters: []contract.ParamDecl{
{Name: "filter-role", Property: "filterRoleIds"},
{Name: "limit", Property: "maxResults"},
// limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize,
// 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。
{Name: "limit"},
{Name: "next-token", Property: "nextToken"},
{Name: "node", Property: "nodeId"},
{Name: "workspace", Property: "workspaceId"},
},
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "next-token"},
},
})
permissionListCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
permissionListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
permissionListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50")
permissionListCmd.Flags().Int("max-results", 0, "")
_ = permissionListCmd.Flags().MarkHidden("max-results")
permissionListCmd.Flags().String("filter-role", "", "按角色过滤(逗号分隔):OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
permissionListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken")
permissionListCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
permissionRemoveCmd := &cobra.Command{
Use: "remove",
Aliases: []string{"rm"},
Short: "移除文档协作者权限",
Long: `从指定节点移除一个或多个协作成员的权限(仅支持 USER 类型)。
Long: `从指定节点移除一个或多个协作成员的权限。
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,只需 type 和 id(USER/DEPT/TAG 还需 corpId)
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId
DEPT 部门,id 为部门 ID,需携带 corpId
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
移除后相关用户将无法通过该节点的直接授权访问内容(若有父节点继承权限则仍可通过继承权限访问)。
注意:
- OWNER 角色不可通过此接口移除。
- 操作者需在该节点具备 EDITOR 及以上角色(OWNER / MANAGER / EDITOR)。
- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
- 单次请求最多 30 个成员,超出请分批调用。
用户 uid 可通过「钉钉通讯录」相关命令检索,如:
dws contact user search --keyword "姓名"`,
Example: ` dws doc permission remove --node DOC_ID --users uid1
dws doc permission remove --node DOC_ID --users uid1,uid2,uid3
dws doc permission remove --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1`,
dws doc permission remove --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1
dws doc permission remove --node DOC_ID --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
if err := validateMembersExclusivity(cmd); err != nil {
return err
}
toolArgs := map[string]any{
"nodeId": nodeID,
"userIds": userIds,
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, true)
if mErr != nil {
return mErr
}
if len(members) > 0 {
toolArgs["members"] = members
} else {
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["userIds"] = userIds
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
@@ -3815,8 +3931,11 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
}
DeclareLeafMetadata(permissionRemoveCmd, LeafSpec{
Safety: contract.SafetySpec{
// 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销多个
// 成员的访问,部门/群聊/角色组还可能间接影响大量用户,与删除同级的
// destructive 入口,必须经过用户确认(--yes 或交互 yes)。
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
@@ -3839,6 +3958,7 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
Examples: []string{"dws doc permission remove --node <DOC_ID> --users uid1 --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
@@ -3846,9 +3966,10 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
},
})
permissionRemoveCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
permissionRemoveCmd.Flags().String("users", "", "被移除权限的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
permissionRemoveCmd.Flags().String("users", "", "被移除权限的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
permissionRemoveCmd.Flags().String("user", "", "")
_ = permissionRemoveCmd.Flags().MarkHidden("user")
permissionRemoveCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥")
permissionRemoveCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
// permission 子命令的 --node 隐藏别名
@@ -4748,16 +4869,30 @@ func wrapDocDeprecatedToTarget(cmd *cobra.Command, targetCmd string) {
}
}
// applyDocReadAccessParams 把 doc read 的访问参数(互联网公开文档密码、历史版本号)
// 附加到 get_document_content 请求上;空密码或未显式设置版本时不发送对应字段,
// 显式 --version 0 表示读取文档初始版本。
func applyDocReadAccessParams(args map[string]any, password string, historyVersion int, historyVersionSet bool) {
if password != "" {
args["password"] = password
}
if historyVersionSet && historyVersion >= 0 {
args["historyVersion"] = historyVersion
}
}
// resolveContentFromFlags 从 --content-file / --content-path / --content / --markdown 获取文档内容。
// 优先级:--content-file/--content-path > --content > --markdown(已弃用别名,向后兼容)。
func runDocReadJsonML(_ *cobra.Command, nodeID string, outputPath string) error {
func runDocReadJsonML(nodeID, outputPath, password string, historyVersion int, historyVersionSet bool) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
resultText, err := callMCPToolReturnText(ctx, "get_document_content", map[string]any{
toolArgs := map[string]any{
"nodeId": nodeID,
"format": "jsonml",
})
}
applyDocReadAccessParams(toolArgs, password, historyVersion, historyVersionSet)
resultText, err := callMCPToolReturnText(ctx, "get_document_content", toolArgs)
if err != nil {
return err
}
@@ -4809,7 +4944,7 @@ func runDocReadJsonML(_ *cobra.Command, nodeID string, outputPath string) error
// runDocReadScope calls get_document_content with JSONML filtering parameters
// and preserves the returned read-only fragment container.
func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool, startBlockID, endBlockID, outputPath string) error {
func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool, startBlockID, endBlockID, outputPath, password string, historyVersion int, historyVersionSet bool) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
@@ -4829,6 +4964,7 @@ func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool,
if endBlockID != "" && scope == "range" {
args["endBlockId"] = endBlockID
}
applyDocReadAccessParams(args, password, historyVersion, historyVersionSet)
resultText, err := callMCPToolReturnTextOnServer(ctx, "doc", "get_document_content", args)
if err != nil {
@@ -5157,3 +5293,85 @@ func collectUserIDs(cmd *cobra.Command) ([]string, error) {
}
return userIds, nil
}
// collectMembers parses the --members JSON array flag (new format), returning
// a members list ready to embed in MCP tool args. Supports USER/DEPT/CONVERSATION/TAG
// member types, each carrying an independent roleId.
//
// When onlyTypeID is true (remove operations), roleId is not required —
// only type and id are needed.
func collectMembers(cmd *cobra.Command, onlyTypeID bool) ([]map[string]any, error) {
raw := mustGetFlag(cmd, "members")
if raw == "" {
return nil, nil
}
var members []map[string]any
if err := json.Unmarshal([]byte(raw), &members); err != nil {
return nil, fmt.Errorf("--members JSON 解析失败: %w", err)
}
if len(members) == 0 {
return nil, fmt.Errorf("--members 不能为空数组")
}
if len(members) > 30 {
return nil, fmt.Errorf("--members 单次最多 30 个成员,超出请分批调用")
}
for i, m := range members {
mt, ok := m["type"].(string)
if !ok {
return nil, fmt.Errorf("--members[%d] 缺少必填字段 type", i)
}
if _, ok := m["id"].(string); !ok {
return nil, fmt.Errorf("--members[%d] 缺少必填字段 id", i)
}
// USER/DEPT/TAG 类型需携带 corpId 用于确定成员所属组织,CONVERSATION 类型选填
if (mt == "USER" || mt == "DEPT" || mt == "TAG") && m["corpId"] == nil {
return nil, fmt.Errorf("--members[%d] 类型 %s 需携带 corpId 以确定所属组织", i, mt)
}
if !onlyTypeID {
if _, ok := m["roleId"].(string); !ok {
return nil, fmt.Errorf("--members[%d] 缺少必填字段 roleId", i)
}
if r, ok := m["roleId"].(string); ok {
m["roleId"] = normalizePermissionRole(r)
}
}
}
return members, nil
}
// validateMembersExclusivity ensures --members (new format) and --users (legacy
// format) are not used simultaneously. Exactly one must be provided.
func validateMembersExclusivity(cmd *cobra.Command) error {
hasMembers := mustGetFlag(cmd, "members") != ""
hasUsers := flagOrFallback(cmd, "users", "user") != ""
if hasMembers && hasUsers {
return fmt.Errorf("--members 与 --users 互斥,不可同时传递")
}
if !hasMembers && !hasUsers {
return fmt.Errorf("必须指定 --members(新格式)或 --users(旧格式)之一")
}
if hasMembers && mustGetFlag(cmd, "role") != "" {
return fmt.Errorf("--members 新格式下不需要 --role,每个 member 携带独立 roleId")
}
return nil
}
// permissionPageSizeFromFlags resolves and validates the page size for the
// permission / member list commands. Both --limit and the hidden
// --max-results alias map to the server pageSize, whose accepted range is
// 1..50 (the backend rejects pageSize > 50 with
// invalidRequest.inputArgs.invalid, and non-positive values are invalid).
// It returns (size, true, nil) when a page size was explicitly provided.
func permissionPageSizeFromFlags(cmd *cobra.Command) (int, bool, error) {
for _, name := range []string{"limit", "max-results"} {
if !cmd.Flags().Changed(name) {
continue
}
size, _ := cmd.Flags().GetInt(name)
if size < 1 || size > 50 {
return 0, false, fmt.Errorf("--%s 取值范围为 1..50(服务端 pageSize 上限 50),当前值 %d", name, size)
}
return size, true, nil
}
return 0, false, nil
}
+1 -1
View File
@@ -144,7 +144,7 @@ func TestCrossPlatformCoverageRunDocReadJSONMLCoverage(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{tc.step}}
installScriptedCaller(t, caller)
err := runDocReadJsonML(&cobra.Command{}, "node", tc.output)
err := runDocReadJsonML("node", tc.output, "", 0, false)
if tc.wantFail && err == nil {
t.Fatal("expected failure")
}
+3 -3
View File
@@ -219,7 +219,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
err := executeDocGroupMentionCommand(
t,
caller,
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
"--emoji", "--mentioned-open-conversation-id", "oc-1",
)
if err == nil || !strings.Contains(err.Error(), "emoji replies do not support group mentions") {
@@ -235,7 +235,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
err := executeDocGroupMentionCommand(
t,
caller,
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
"--emoji", "--mentioned-open-conversation-id", " ",
)
if err == nil || !strings.Contains(err.Error(), "must not be empty") {
@@ -251,7 +251,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
err := executeDocGroupMentionCommand(
t,
caller,
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
"--emoji",
)
if err != nil {
+95 -5
View File
@@ -77,7 +77,7 @@ func TestCrossPlatformCoverageDocReadScopeFlagsAndValidation(t *testing.T) {
if err != nil || len(remaining) != 0 {
t.Fatalf("find read: remaining=%v err=%v", remaining, err)
}
for _, name := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id"} {
for _, name := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id", "password", "version"} {
if read.Flags().Lookup(name) == nil {
t.Fatalf("doc read missing --%s", name)
}
@@ -249,7 +249,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output := installDocReadScopeCaller(t, tt.caller)
err := runDocReadScope("doc-1", "", "", 0, false, "", "ignored", tt.output)
err := runDocReadScope("doc-1", "", "", 0, false, "", "ignored", tt.output, "", 0, false)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %v, want %q", err, tt.wantErr)
@@ -277,7 +277,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{}]"}`}
stdout := installDocReadScopeCaller(t, caller)
outputPath := filepath.Join(t.TempDir(), "fragment.json")
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath); err != nil {
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath, "", 0, false); err != nil {
t.Fatalf("runDocReadScope: %v", err)
}
var receipt map[string]any
@@ -292,7 +292,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
t.Run("human output reports missing fragment", func(t *testing.T) {
caller := &docReadScopeCaller{text: `{}`, format: "raw"}
stdout := installDocReadScopeCaller(t, caller)
if err := runDocReadScope("doc-1", "", "", 0, false, "", "", ""); err != nil {
if err := runDocReadScope("doc-1", "", "", 0, false, "", "", "", "", 0, false); err != nil {
t.Fatalf("runDocReadScope: %v", err)
}
if !strings.Contains(stdout.String(), "未匹配到节点") {
@@ -304,7 +304,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{}]"}`, format: "raw"}
stdout := installDocReadScopeCaller(t, caller)
outputPath := filepath.Join(t.TempDir(), "fragment.json")
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath); err != nil {
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath, "", 0, false); err != nil {
t.Fatalf("runDocReadScope: %v", err)
}
if !strings.Contains(stdout.String(), "JSONML fragment 已写入 "+outputPath) {
@@ -312,3 +312,93 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
}
})
}
// TestCrossPlatformCoverageDocReadAccessParamsForwarding 验证 doc read 三条读取路径
// 都把 --password / --version 透传到 get_document_content,且非法版本号在
// 本地校验阶段被拒绝、不产生远端调用。
func TestCrossPlatformCoverageDocReadAccessParamsForwarding(t *testing.T) {
t.Run("markdown path forwards password and history version", func(t *testing.T) {
caller := &docReadScopeCaller{text: `{"markdown":"body"}`}
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--password", "pw", "--version", "7"); err != nil {
t.Fatalf("execute: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %d, want 1", len(caller.calls))
}
want := map[string]any{"nodeId": "doc-1", "password": "pw", "historyVersion": 7}
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
}
})
t.Run("jsonml path forwards password and history version", func(t *testing.T) {
caller := &docReadScopeCaller{text: `{"jsonml":"[\"root\",{}]","revision":7}`}
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--content-format", "jsonml", "--password", "pw", "--version", "7"); err != nil {
t.Fatalf("execute: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %d, want 1", len(caller.calls))
}
want := map[string]any{"nodeId": "doc-1", "format": "jsonml", "password": "pw", "historyVersion": 7}
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
}
})
t.Run("scope path forwards password and history version", func(t *testing.T) {
caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{},[\"h1\",{},\"t\"]]"}`}
if err := executeDocReadScopeCommand(t, caller,
"read", "--node", "doc-1", "--content-format", "jsonml", "--scope", "outline",
"--password", "pw", "--version", "7"); err != nil {
t.Fatalf("execute: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %d, want 1", len(caller.calls))
}
want := map[string]any{"nodeId": "doc-1", "format": "jsonml", "scope": "outline", "password": "pw", "historyVersion": 7}
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
}
})
t.Run("negative history version is rejected locally", func(t *testing.T) {
for _, raw := range []string{"-1", "-3"} {
caller := &docReadScopeCaller{}
err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--version", raw)
if err == nil || !strings.Contains(err.Error(), "--version 必须为非负整数") {
t.Fatalf("--version %s error = %v, want invalid --version", raw, err)
}
if len(caller.calls) != 0 {
t.Fatalf("--version %s produced %d remote calls, want 0", raw, len(caller.calls))
}
}
})
t.Run("explicit zero history version forwards initial version", func(t *testing.T) {
caller := &docReadScopeCaller{text: `{"markdown":"body"}`}
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--version", "0"); err != nil {
t.Fatalf("execute: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %d, want 1", len(caller.calls))
}
want := map[string]any{"nodeId": "doc-1", "historyVersion": 0}
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
}
})
t.Run("unset history version is not forwarded", func(t *testing.T) {
caller := &docReadScopeCaller{text: `{"markdown":"body"}`}
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1"); err != nil {
t.Fatalf("execute: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %d, want 1", len(caller.calls))
}
want := map[string]any{"nodeId": "doc-1"}
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
}
})
}
+141 -44
View File
@@ -2042,27 +2042,55 @@ func newDriveCommand() *cobra.Command {
drivePermAddCmd := &cobra.Command{
Use: "add",
Short: "添加协作者",
Args: cobra.NoArgs,
Long: `为文档空间节点添加协作成员并授予指定角色。
支持的角色 (--role): MANAGER / EDITOR / DOWNLOADER / READER`,
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织)
DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织)
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型
支持的角色: MANAGER / EDITOR / DOWNLOADER / READER
--notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false。
省略 --notify 时 CLI 不向服务端发送该字段,服务端按不通知处理;需要通知请显式传 --notify。`,
Example: ` dws drive permission add --node DOC_ID --users uid1 --role READER
dws drive permission add --node DOC_ID --users uid1,uid2 --role EDITOR`,
dws drive permission add --node DOC_ID --users uid1,uid2 --role EDITOR
dws drive permission add --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"}]' --notify
dws drive permission add --node DOC_ID --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "role"); err != nil {
if err := validateMembersExclusivity(cmd); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, false)
if mErr != nil {
return mErr
}
toolArgs := map[string]any{
"nodeId": nodeID,
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
"userIds": userIds,
if len(members) > 0 {
toolArgs["members"] = members
if cmd.Flags().Changed("notify") {
notify, _ := cmd.Flags().GetBool("notify")
toolArgs["notify"] = notify
}
} else {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
toolArgs["userIds"] = userIds
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
@@ -2103,7 +2131,9 @@ func newDriveCommand() *cobra.Command {
Examples: []string{"dws drive permission add --node <ID> --users uid1,uid2 --role READER --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "notify", Property: "notify"},
{Name: "role", Property: "roleId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
@@ -2111,35 +2141,64 @@ func newDriveCommand() *cobra.Command {
},
})
drivePermAddCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermAddCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
drivePermAddCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
drivePermAddCmd.Flags().String("user", "", "")
_ = drivePermAddCmd.Flags().MarkHidden("user")
drivePermAddCmd.Flags().String("role", "", "角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)")
drivePermAddCmd.Flags().String("role", "", "角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填)")
drivePermAddCmd.Flags().String("workspace", "", "知识库 ID (选填)")
drivePermAddCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
drivePermAddCmd.Flags().Bool("notify", false, "是否通知被添加的成员(仅 --members 新格式时生效,需显式传入才通知)")
drivePermUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新协作者权限",
Args: cobra.NoArgs,
Long: `更新文档空间节点已有协作者的权限角色。
支持的角色 (--role): MANAGER / EDITOR / DOWNLOADER / READER`,
Example: ` dws drive permission update --node DOC_ID --users uid1 --role EDITOR`,
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId
DEPT 部门,id 为部门 ID,需携带 corpId
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
支持的角色: MANAGER / EDITOR / DOWNLOADER / READER
--notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知,默认 false。`,
Example: ` dws drive permission update --node DOC_ID --users uid1 --role EDITOR
dws drive permission update --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
dws drive permission update --node DOC_ID --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "role"); err != nil {
if err := validateMembersExclusivity(cmd); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, false)
if mErr != nil {
return mErr
}
toolArgs := map[string]any{
"nodeId": nodeID,
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
"userIds": userIds,
if len(members) > 0 {
toolArgs["members"] = members
if cmd.Flags().Changed("notify") {
notify, _ := cmd.Flags().GetBool("notify")
toolArgs["notify"] = notify
}
} else {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
toolArgs["userIds"] = userIds
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
@@ -2176,7 +2235,9 @@ func newDriveCommand() *cobra.Command {
Examples: []string{"dws drive permission update --node <ID> --users uid1 --role EDITOR --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "notify", Property: "notify"},
{Name: "role", Property: "roleId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
@@ -2184,33 +2245,39 @@ func newDriveCommand() *cobra.Command {
},
})
drivePermUpdateCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermUpdateCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
drivePermUpdateCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
drivePermUpdateCmd.Flags().String("user", "", "")
_ = drivePermUpdateCmd.Flags().MarkHidden("user")
drivePermUpdateCmd.Flags().String("role", "", "新角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)")
drivePermUpdateCmd.Flags().String("role", "", "新角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填)")
drivePermUpdateCmd.Flags().String("workspace", "", "知识库 ID (选填)")
drivePermUpdateCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
drivePermUpdateCmd.Flags().Bool("notify", false, "是否通知被变更的成员(仅 --members 新格式时生效)")
drivePermListCmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "查询协作者列表",
Long: `查询文档空间节点的协作者列表。`,
Long: `查询文档空间节点的协作者列表,支持分页和角色过滤。
底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。
出参包含 totalCount、hasMore 和 nextToken。
当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。`,
Example: ` dws drive permission list --node DOC_ID
dws drive permission list --node DOC_ID --limit 100 --filter-role MANAGER,EDITOR`,
dws drive permission list --node DOC_ID --limit 50 --filter-role MANAGER,EDITOR
dws drive permission list --node DOC_ID --next-token <上次返回的 nextToken>`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
limit := 0
if cmd.Flags().Changed("limit") {
limit, _ = cmd.Flags().GetInt("limit")
} else if cmd.Flags().Changed("max-results") {
limit, _ = cmd.Flags().GetInt("max-results")
if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil {
return err
} else if ok {
toolArgs["pageSize"] = size
}
if limit > 0 {
toolArgs["maxResults"] = limit
if v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" {
toolArgs["nextToken"] = v
}
if v := mustGetFlag(cmd, "filter-role"); v != "" {
toolArgs["filterRoleIds"] = parseRoleList(v)
@@ -2251,38 +2318,63 @@ func newDriveCommand() *cobra.Command {
},
Parameters: []contract.ParamDecl{
{Name: "filter-role", Property: "filterRoleIds"},
{Name: "limit", Property: "maxResults"},
// limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize,
// 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。
{Name: "limit"},
{Name: "next-token", Property: "nextToken"},
{Name: "node", Property: "nodeId"},
{Name: "workspace", Property: "workspaceId"},
},
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "next-token"},
},
})
drivePermListCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50")
drivePermListCmd.Flags().Int("max-results", 0, "")
_ = drivePermListCmd.Flags().MarkHidden("max-results")
drivePermListCmd.Flags().String("filter-role", "", "按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
drivePermListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken")
drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)")
drivePermRemoveCmd := &cobra.Command{
Use: "remove",
Aliases: []string{"rm"},
Short: "移除协作者权限",
Long: `从文档空间节点移除协作成员的权限。`,
Long: `从文档空间节点移除协作成员的权限。
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,只需 type 和 id(USER/DEPT/TAG 还需 corpId)
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId
DEPT 部门,id 为部门 ID,需携带 corpId
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId`,
Example: ` dws drive permission remove --node DOC_ID --users uid1
dws drive permission remove --node DOC_ID --users uid1,uid2`,
dws drive permission remove --node DOC_ID --users uid1,uid2
dws drive permission remove --node DOC_ID --members '[{"type":"USER","id":"uid1","corpId":"xxx"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
if err := validateMembersExclusivity(cmd); err != nil {
return err
}
toolArgs := map[string]any{
"nodeId": nodeID,
"userIds": userIds,
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, true)
if mErr != nil {
return mErr
}
if len(members) > 0 {
toolArgs["members"] = members
} else {
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["userIds"] = userIds
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
@@ -2292,8 +2384,11 @@ func newDriveCommand() *cobra.Command {
}
DeclareLeafMetadata(drivePermRemoveCmd, LeafSpec{
Safety: contract.SafetySpec{
// 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销多个
// 成员的访问,部门/群聊/角色组还可能间接影响大量用户,与删除同级的
// destructive 入口,必须经过用户确认(--yes 或交互 yes)。
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
@@ -2319,6 +2414,7 @@ func newDriveCommand() *cobra.Command {
Examples: []string{"dws drive permission remove --node <ID> --users uid1 --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
@@ -2326,9 +2422,10 @@ func newDriveCommand() *cobra.Command {
},
})
drivePermRemoveCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermRemoveCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
drivePermRemoveCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
drivePermRemoveCmd.Flags().String("user", "", "")
_ = drivePermRemoveCmd.Flags().MarkHidden("user")
drivePermRemoveCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥")
drivePermRemoveCmd.Flags().String("workspace", "", "知识库 ID (选填)")
// permission 子命令 --node 隐藏别名(保持与迁移前 doc 命令一致)
+2 -2
View File
@@ -108,7 +108,7 @@ func driveFolderPushResultSpec() *contract.ResultSpec {
"preview_kind":{"type":"string","description":"预览类型","enum":["plan"]},
"operation":{"type":"string","description":"预览的命令"},
"if_exists":{"type":"string","description":"远端同名文件处理策略"},
"plan":{"type":"object","description":"预览的推送计划","properties":{"summary":{"type":"object","description":"计划动作计数","properties":{"uploaded":{"type":"integer","description":"计划上传或覆盖的文件数"},"skipped":{"type":"integer","description":"计划跳过的文件数"},"failed":{"type":"integer","description":"预检失败的条目数"},"aborted":{"type":"boolean","description":"计划是否会中止"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false}
"plan":{"type":"object","description":"预览的推送计划","properties":{"summary":{"type":"object","description":"计划动作计数;兼容执行计数 uploaded/skipped 在预览中保持为零","properties":{"uploaded":{"type":"integer","description":"兼容字段;预览未执行上传,固定为零"},"skipped":{"type":"integer","description":"兼容字段;预览未执行跳过,固定为零"},"failed":{"type":"integer","description":"预检失败的条目数"},"aborted":{"type":"boolean","description":"计划是否会中止"},"planned_uploads":{"type":"integer","description":"计划上传或覆盖的文件数"},"planned_skips":{"type":"integer","description":"计划按策略跳过的文件数"},"planned_folders":{"type":"integer","description":"计划创建的远端目录数"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"未执行的 planned_* 计划动作或已确认的预检失败"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false}
},
"additionalProperties":false
}`),
@@ -134,7 +134,7 @@ func driveFolderSyncResultSpec() *contract.ResultSpec {
"executed":{"type":"boolean","description":"是否执行了写操作","const":false},
"preview_kind":{"type":"string","description":"预览类型","enum":["plan"]},
"operation":{"type":"string","description":"预览的命令","const":"drive sync"},
"plan":{"type":"object","description":"预览的同步计划","properties":{"detection":{"type":"string","description":"计划使用的差异检测模式","enum":["exact","quick"]},"diff":{"type":"object","description":"计划执行前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},"summary":{"type":"object","description":"计划动作计数","properties":{"pulled":{"type":"integer","description":"计划拉取的条目数"},"pushed":{"type":"integer","description":"计划推送的条目数"},"skipped":{"type":"integer","description":"计划跳过的条目数"},"failed":{"type":"integer","description":"预检失败的条目数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["detection","diff","summary","items"],"additionalProperties":false}
"plan":{"type":"object","description":"预览的同步计划","properties":{"detection":{"type":"string","description":"计划使用的差异检测模式","enum":["exact","quick"]},"diff":{"type":"object","description":"计划执行前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},"summary":{"type":"object","description":"计划动作计数;兼容执行计数 pulled/pushed/skipped 在预览中保持为零","properties":{"pulled":{"type":"integer","description":"兼容字段;预览未执行拉取,固定为零"},"pushed":{"type":"integer","description":"兼容字段;预览未执行推送,固定为零"},"skipped":{"type":"integer","description":"兼容字段;预览未执行跳过,固定为零"},"failed":{"type":"integer","description":"预检失败的条目数"},"planned_pulls":{"type":"integer","description":"计划拉取的文件数"},"planned_pushes":{"type":"integer","description":"计划上传或覆盖的文件数"},"planned_skips":{"type":"integer","description":"计划按策略跳过的文件数"},"planned_folders":{"type":"integer","description":"计划创建的远端目录数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"未执行的 planned_* 计划动作、待决策动作或预检失败"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["detection","diff","summary","items"],"additionalProperties":false}
},
"oneOf":[
{"required":["detection","diff","summary","items"]},
@@ -93,10 +93,26 @@ func TestCrossPlatformCoverageDriveFolderContractsPublishResultAndDryRun(t *test
if name == "sync" {
assertDriveSyncResultSchema(t, final.Result.DataSchema)
}
if name == "push" {
assertDrivePushResultSchema(t, final.Result.DataSchema)
}
})
}
}
func assertDrivePushResultSchema(t *testing.T, raw json.RawMessage) {
t.Helper()
properties := resultSchemaProperties(t, raw)
plan := schemaObjectProperty(t, properties, "plan")
summary := schemaObjectProperty(t, schemaProperties(t, plan), "summary")
summaryProperties := schemaProperties(t, summary)
for _, name := range []string{"planned_uploads", "planned_skips", "planned_folders"} {
if _, ok := summaryProperties[name]; !ok {
t.Fatalf("push plan summary schema is missing %s", name)
}
}
}
func resultSchemaProperties(t *testing.T, raw json.RawMessage) map[string]any {
t.Helper()
var schema map[string]any
@@ -133,6 +149,13 @@ func assertDriveSyncResultSchema(t *testing.T, raw json.RawMessage) {
planProperties := schemaProperties(t, plan)
planDiff := schemaObjectProperty(t, planProperties, "diff")
assertSchemaRequired(t, planDiff, "new_local", "new_remote", "modified", "unchanged", "unknown")
planSummary := schemaObjectProperty(t, planProperties, "summary")
planSummaryProperties := schemaProperties(t, planSummary)
for _, name := range []string{"planned_pulls", "planned_pushes", "planned_skips", "planned_folders"} {
if _, ok := planSummaryProperties[name]; !ok {
t.Fatalf("sync plan summary schema is missing %s", name)
}
}
var schema map[string]any
if err := json.Unmarshal(raw, &schema); err != nil {
+26 -14
View File
@@ -39,6 +39,11 @@ const (
pushActionSkipped = "skipped" // 按 --if-exists 跳过
pushActionFolderCreated = "folder_created" // 新建远端目录(不计入 uploaded)
pushActionFailed = "failed"
pushActionPlannedUpload = "planned_upload"
pushActionPlannedOverwrite = "planned_overwrite"
pushActionPlannedSkip = "planned_skip"
pushActionPlannedFolderCreate = "planned_folder_create"
)
// localPushFile 描述一个待推送的本地常规文件。
@@ -79,10 +84,13 @@ type drivePushItem struct {
// drivePushSummary 是各动作的计数汇总。uploaded 同时统计新建与覆盖。
type drivePushSummary struct {
Uploaded int `json:"uploaded"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Aborted bool `json:"aborted"`
Uploaded int `json:"uploaded"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Aborted bool `json:"aborted"`
PlannedUploads int `json:"planned_uploads,omitempty"`
PlannedSkips int `json:"planned_skips,omitempty"`
PlannedFolders int `json:"planned_folders,omitempty"`
}
type drivePushResult struct {
@@ -380,7 +388,8 @@ func printDrivePushDryRunWithPreflight(ifExists string, remoteFiles map[string]*
continue
}
plannedFolders[dir] = "dry-run-planned-folder"
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionFolderCreated})
plan.Summary.PlannedFolders++
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionPlannedFolderCreate})
}
for _, lf := range localFiles {
size := lf.Size
@@ -390,25 +399,25 @@ func printDrivePushDryRunWithPreflight(ifExists string, remoteFiles map[string]*
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: "父目录未能创建"})
continue
}
action := pushActionUploaded
action := pushActionPlannedUpload
if rf, exists := remoteFiles[lf.RelPath]; exists {
switch ifExists {
case ifExistsSkip:
action = pushActionSkipped
action = pushActionPlannedSkip
case ifExistsSmart:
if rf.ModifiedTimeValid && rf.ModifiedTime >= lf.ModTimeMillis {
action = pushActionSkipped
action = pushActionPlannedSkip
} else {
action = pushActionOverwritten
action = pushActionPlannedOverwrite
}
case ifExistsOverwrite:
action = pushActionOverwritten
action = pushActionPlannedOverwrite
}
}
if action == pushActionSkipped {
plan.Summary.Skipped++
if action == pushActionPlannedSkip {
plan.Summary.PlannedSkips++
} else {
plan.Summary.Uploaded++
plan.Summary.PlannedUploads++
}
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: action, SizeBytes: &size})
}
@@ -811,7 +820,10 @@ func defaultPushPutOpenedFile(ctx context.Context, url string, headers map[strin
if _, err := file.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek upload file: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, file)
// net/http owns and closes Request.Body after RoundTrip. The *os.File belongs
// to pushUploadFilePinned, which must stat it after PUT before commit_upload;
// wrap it in a no-op closer so HTTP completion cannot close that shared handle.
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, io.NopCloser(file))
if err != nil {
return fmt.Errorf("failed to create upload request: %w", err)
}
@@ -421,6 +421,29 @@ func TestCrossPlatformCoverageDrivePushFinalDefaultOpenedPUTAndCommit(t *testing
}
}
func TestCrossPlatformCoverageDrivePushOpenedPUTKeepsCallerOwnedHandle(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
_, _ = io.Copy(io.Discard, request.Body)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
path := filepath.Join(t.TempDir(), "payload")
mustWrite(t, path, "payload")
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
if err := defaultPushPutOpenedFile(context.Background(), server.URL, nil, file, 7); err != nil {
t.Fatalf("PUT failed: %v", err)
}
if _, err := file.Stat(); err != nil {
t.Fatalf("HTTP PUT closed its caller-owned file handle: %v", err)
}
}
func TestCrossPlatformCoverageDriveSyncFinalPreflightAndKeepBothErrors(t *testing.T) {
t.Run("local target preflight lstat error and directory", func(t *testing.T) {
_, root, _ := openDriveFinalCoverageRoot(t)
+120 -4
View File
@@ -65,6 +65,14 @@ const (
syncActionRenamedLocal = "renamed_local"
syncActionSkipped = "skipped"
syncActionFailed = "failed"
syncActionPlannedDownload = "planned_download"
syncActionPlannedUpload = "planned_upload"
syncActionPlannedOverwrite = "planned_overwrite"
syncActionPlannedFolderCreate = "planned_folder_create"
syncActionPlannedRenameLocal = "planned_rename_local"
syncActionPlannedSkip = "planned_skip"
syncActionDecisionRequired = "decision_required"
)
// driveSyncItem 是输出 items[] 中每条操作的明细。
@@ -77,10 +85,14 @@ type driveSyncItem struct {
// driveSyncSummary 是各方向的计数汇总。
type driveSyncSummary struct {
Pulled int `json:"pulled"`
Pushed int `json:"pushed"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Pulled int `json:"pulled"`
Pushed int `json:"pushed"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
PlannedPulls int `json:"planned_pulls,omitempty"`
PlannedPushes int `json:"planned_pushes,omitempty"`
PlannedSkips int `json:"planned_skips,omitempty"`
PlannedFolders int `json:"planned_folders,omitempty"`
}
// driveSyncDiff 是本次同步前算出的五类差异(与 status 同源)。
@@ -267,6 +279,12 @@ func runDriveSync(cmd *cobra.Command, _ []string) error {
// --dry-run:只算差异、不执行任何同步动作。
if deps.Caller.DryRun() {
if len(preflight) == 0 && res.Summary.Failed == 0 {
appendDriveSyncDryRunPlan(&res, driveSyncDryRunPlanInput{
LocalDirs: localDirs, LocalByRel: localByRel, RemoteFiles: remoteFiles, RemoteFolders: remoteFolders,
NewLocal: newLocal, NewRemote: newRemote, Modified: modified, Unknown: unknown, OnConflict: onConflict,
})
}
return deps.Out.PrintJSON(driveSyncDryRunResult{
DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive sync", Plan: res,
})
@@ -394,6 +412,104 @@ func runDriveSync(cmd *cobra.Command, _ []string) error {
return nil
}
type driveSyncDryRunPlanInput struct {
LocalDirs []string
LocalByRel map[string]localPushFile
RemoteFiles map[string]*remoteFile
RemoteFolders map[string]string
NewLocal []string
NewRemote []string
Modified []string
Unknown []string
OnConflict string
}
func appendDriveSyncDryRunPlan(res *driveSyncResult, input driveSyncDryRunPlanInput) {
for _, rel := range input.Unknown {
res.Summary.PlannedSkips++
res.Items = append(res.Items, driveSyncItem{
RelPath: rel, Action: syncActionPlannedSkip, Direction: syncDirectionConflict,
Error: "远端无可靠 md5,内容无法核对,执行时会跳过(可改用 --quick 按 modified_time 比对)",
})
}
plannedFolders := make(map[string]string, len(input.RemoteFolders)+len(input.LocalDirs))
for rel, fileID := range input.RemoteFolders {
plannedFolders[rel] = fileID
}
for _, dir := range input.LocalDirs {
if _, ok := plannedFolders[dir]; ok {
continue
}
parentRel, _ := splitRel(dir)
if plannedFolders[parentRel] == "" {
res.Summary.Failed++
res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"})
continue
}
plannedFolders[dir] = "dry-run-planned-folder"
res.Summary.PlannedFolders++
res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionPlannedFolderCreate, Direction: syncDirectionPush})
}
for _, rel := range input.NewRemote {
res.Summary.PlannedPulls++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull})
}
for _, rel := range input.NewLocal {
parentRel, _ := splitRel(rel)
if plannedFolders[parentRel] == "" {
res.Summary.Failed++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"})
continue
}
res.Summary.PlannedPushes++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedUpload, Direction: syncDirectionPush})
}
occupied := make(map[string]bool, len(input.LocalByRel)+len(input.LocalDirs)+len(input.RemoteFiles)+len(input.RemoteFolders))
for rel := range input.LocalByRel {
occupied[rel] = true
}
for _, rel := range input.LocalDirs {
occupied[rel] = true
}
for rel := range input.RemoteFiles {
occupied[rel] = true
}
for rel := range input.RemoteFolders {
if rel != "" {
occupied[rel] = true
}
}
for _, rel := range input.Modified {
switch input.OnConflict {
case syncConflictRemoteWins:
res.Summary.PlannedPulls++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull})
case syncConflictLocalWins:
res.Summary.PlannedPushes++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedOverwrite, Direction: syncDirectionConflict})
case syncConflictKeepBoth:
candidate := driveSyncSuffixedRel(rel, input.RemoteFiles[rel].FileID, occupied)
occupied[candidate] = true
res.Summary.PlannedPulls++
res.Items = append(res.Items,
driveSyncItem{RelPath: candidate, Action: syncActionPlannedRenameLocal, Direction: syncDirectionConflict},
driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull},
)
case syncConflictAsk:
res.Items = append(res.Items, driveSyncItem{
RelPath: rel, Action: syncActionDecisionRequired, Direction: syncDirectionConflict,
Error: "执行时需要交互选择 remote-wins、local-wins、keep-both 或 skip",
})
default:
res.Summary.PlannedSkips++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedSkip, Direction: syncDirectionConflict})
}
}
}
// syncPathBlockedByTypeConflict 判断 rel 本身或任一祖先是否是文件/目录类型冲突根。
// 祖先检查保证冲突目录下的本地、远端后代不会绕过根路径的 fail-closed 结论。
func syncPathBlockedByTypeConflict(rel string, conflicts map[string]string) bool {
@@ -3,6 +3,7 @@ package helpers
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
@@ -189,6 +190,116 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlansWithoutRemoteWrites(t *testin
}
}
func TestCrossPlatformCoverageDrivePushDryRunPublishesPlannedNotExecutedActions(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "local.txt"), "local")
caller := syncCaller(nil)
caller.dryRun = true
var out bytes.Buffer
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
testseam.Swap(t, &os.Args, []string{"dws", "drive", "push"})
cmd := findDriveSubcommand(t, "push")
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("dry-run push: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("decode dry-run push: %v\n%s", err, out.String())
}
plan := payload["plan"].(map[string]any)
summary := plan["summary"].(map[string]any)
if summary["uploaded"] != float64(0) || summary["planned_uploads"] != float64(1) {
t.Fatalf("dry-run summary reports execution instead of a plan: %#v", summary)
}
items := plan["items"].([]any)
if got := items[0].(map[string]any)["action"]; got != "planned_upload" {
t.Fatalf("dry-run action = %v, want planned_upload", got)
}
}
func TestCrossPlatformCoverageDriveSyncDryRunReturnsRealActionPlan(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "local.txt"), "local")
mustWrite(t, filepath.Join(dir, "shared.txt"), "shared")
if err := os.Mkdir(filepath.Join(dir, "empty-folder"), 0o755); err != nil {
t.Fatal(err)
}
caller := syncCaller(map[string]string{
"ROOT": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2},{"name":"shared.txt","type":"file","fileId":"S","modifyTime":2}],"nextToken":""}}`,
})
caller.dryRun = true
var out bytes.Buffer
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
testseam.Swap(t, &os.Args, []string{"dws", "drive", "sync"})
cmd := findDriveSubcommand(t, "sync")
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("dry-run sync: %v", err)
}
var payload driveSyncDryRunResult
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("decode dry-run sync: %v\n%s", err, out.String())
}
if payload.Executed || payload.Plan.Summary.Pulled != 0 || payload.Plan.Summary.Pushed != 0 || payload.Plan.Summary.Skipped != 0 {
t.Fatalf("dry-run plan summary = %+v, executed=%v", payload.Plan.Summary, payload.Executed)
}
if payload.Plan.Summary.PlannedPulls != 1 || payload.Plan.Summary.PlannedPushes != 1 ||
payload.Plan.Summary.PlannedSkips != 1 || payload.Plan.Summary.PlannedFolders != 1 {
t.Fatalf("dry-run planned summary = %+v", payload.Plan.Summary)
}
actions := map[string]int{}
for _, item := range payload.Plan.Items {
actions[item.Action]++
}
if len(payload.Plan.Items) != 4 || actions["planned_download"] != 1 || actions["planned_upload"] != 1 ||
actions["planned_skip"] != 1 || actions["planned_folder_create"] != 1 {
t.Fatalf("dry-run action plan = %#v", payload.Plan.Items)
}
for _, tool := range []string{"download_file", "get_upload_info", "commit_upload", "create_folder"} {
if calls := caller.callsFor(tool); len(calls) != 0 {
t.Fatalf("dry-run called %s: %#v", tool, calls)
}
}
}
func TestCrossPlatformCoverageDriveSyncDryRunPlanBranchMatrix(t *testing.T) {
remoteFiles := map[string]*remoteFile{
"conflict.txt": {FileID: "remote-file"},
}
remoteFolders := map[string]string{"": "root", "existing": "existing-id"}
localDirs := []string{"existing", "new", "missing/child"}
localFiles := map[string]localPushFile{"conflict.txt": {}, "new/file.txt": {}}
for _, policy := range []string{syncConflictRemoteWins, syncConflictLocalWins, syncConflictKeepBoth, syncConflictAsk, syncConflictSkip} {
t.Run(policy, func(t *testing.T) {
res := &driveSyncResult{}
appendDriveSyncDryRunPlan(res, driveSyncDryRunPlanInput{
LocalDirs: localDirs, LocalByRel: localFiles, RemoteFiles: remoteFiles, RemoteFolders: remoteFolders,
NewLocal: []string{"new/file.txt", "missing/file.txt"}, NewRemote: []string{"remote.txt"},
Modified: []string{"conflict.txt"}, Unknown: []string{"unknown.txt"}, OnConflict: policy,
})
if res.Summary.PlannedSkips == 0 || res.Summary.PlannedFolders == 0 || res.Summary.PlannedPulls == 0 || res.Summary.Failed != 2 {
t.Fatalf("dry-run plan summary for %s = %#v; items=%#v", policy, res.Summary, res.Items)
}
switch policy {
case syncConflictRemoteWins, syncConflictKeepBoth:
if res.Summary.PlannedPulls < 2 {
t.Fatalf("%s planned pulls = %d", policy, res.Summary.PlannedPulls)
}
case syncConflictLocalWins:
if res.Summary.PlannedPushes < 2 {
t.Fatalf("local wins planned pushes = %d", res.Summary.PlannedPushes)
}
}
})
}
}
func TestCrossPlatformCoverageDrivePull_dryRunPlanBranches(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "skip.txt"), "skip")
@@ -242,7 +353,7 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) {
[]string{"existing", "missing/child"}, files("orphan/a.txt", "skip.txt", "new.txt")); err != nil {
t.Fatalf("skip plan: %v", err)
}
if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionUploaded) {
if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedUpload) {
t.Fatalf("skip plan did not cover failed/skipped/uploaded: %s", text)
}
out.Reset()
@@ -250,14 +361,14 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) {
files("smart-skip.txt", "smart-overwrite.txt")); err != nil {
t.Fatalf("smart plan: %v", err)
}
if text := out.String(); !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionOverwritten) {
if text := out.String(); !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedOverwrite) {
t.Fatalf("smart plan did not cover skip/overwrite: %s", text)
}
out.Reset()
if err := printDrivePushDryRun(ifExistsOverwrite, remoteFiles, remoteFolders, nil, files("overwrite.txt")); err != nil {
t.Fatalf("overwrite plan: %v", err)
}
if !strings.Contains(out.String(), pushActionOverwritten) {
if !strings.Contains(out.String(), pushActionPlannedOverwrite) {
t.Fatalf("overwrite plan must overwrite existing file: %s", out.String())
}
}
+145 -2
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
const (
@@ -134,6 +136,13 @@ func WrapErrorWithOperation(err error, operation string) error {
if _, ok := err.(*PATError); ok {
return err
}
// 框架确认门禁错误(deferred ConfirmSafety 从 CallTool 返回)必须原样透传:
// “加 --yes 重试”的语义只能由 reason=confirmation_required 表达,文本分类会
// 误路由(commandPath 含 "permission" 的命令会被判成 AUTH_PERMISSION_DENIED,
// 其余则丢失 reason 退化为 UNCLASSIFIED)。
if apperrors.IsConfirmationRequired(err) {
return err
}
msg := err.Error()
// File lock timeout (cross-process lock)
@@ -254,6 +263,24 @@ func WrapErrorWithOperation(err error, operation string) error {
}
}
// 服务端返回的用户/成员参数校验错误(如“用户不存在”、“不属于当前组织”),
// 文本含“不存在”但并非文档资源缺失,需在 RESOURCE_NOT_FOUND 分类之前拦截,
// 透传服务端原始错误信息。
if errContainsAny(msg, "用户不存在", "不属于当前组织", "成员不存在") {
errMsg := msg
var body map[string]any
if json.Unmarshal([]byte(msg), &body) == nil {
errMsg = businessErrorDisplayMessage(body, msg)
}
return &CLIError{
Code: CodeMCPToolError,
Message: errMsg,
Suggestion: "请检查用户 ID 或组织信息是否正确,跨组织用户需通过 --members 格式携带 corpId",
Operation: operation,
Cause: err,
}
}
// Resource not found
if errContainsAny(msg, "not found", "不存在", "资源不存在") {
suggestion := "Check if the resource exists or if your account has permission"
@@ -379,21 +406,98 @@ func suggestForBusinessErrorText(text string) string {
return "请确认邮箱地址正确,查看可用邮箱: dws mail mailbox list"
case strings.Contains(text, "频率超限") || strings.Contains(text, "rate limit"):
return "API rate limit exceeded, wait a moment and retry"
case strings.Contains(text, "用户不存在") || strings.Contains(text, "不属于当前组织") ||
strings.Contains(text, "成员不存在"):
return "请检查用户 ID 或组织信息是否正确,跨组织用户需通过 --members 格式携带 corpId"
case strings.Contains(text, "未找到指定工具") || strings.Contains(text, "MCP不存在"):
return "后端工具未注册或已下线;这不是参数格式问题。请升级到包含该工具注册的后端/静态端点版本,或改用当前可用替代命令。"
case strings.Contains(text, "参数错误") || strings.Contains(text, "param error"):
return "Check input parameters. Use --help for available flags"
case strings.Contains(text, "无权限访问") || strings.Contains(text, "没有访问权限") ||
strings.Contains(text, "没有权限") || strings.Contains(text, "权限不足") ||
strings.Contains(text, "no permission") || strings.Contains(text, "permission denied") ||
strings.Contains(text, "FORBIDDEN"):
return permissionDeniedSuggestion(text)
default:
return ""
}
}
// documentPermissionServerCodes are permission-denied codes that are
// demonstrably drive-specific (their identity carries the forbidden.* domain
// prefix). Only these (plus the document/wiki role-threshold message wording)
// justify the dws drive permission apply-* guidance. Generic code names such
// as NO_PERMISSION and FORBIDDEN are deliberately excluded: attendance
// (get-self-setting) and event-subscription tools have been observed returning
// NO_PERMISSION, so keying guidance on it would mislead non-document products
// exactly the way this classification tries to avoid.
var documentPermissionServerCodes = map[string]bool{
"forbidden.no.auth": true,
"forbidden.accessDenied": true,
}
// documentPermissionErrorText reports whether a permission-denied message is
// specific to document/wiki permission (drive/doc/wiki), e.g. the role
// threshold wording emitted by the member-management APIs or the document
// permission error codes embedded in the message text.
func documentPermissionErrorText(text string) bool {
if text == "" {
return false
}
lower := strings.ToLower(text)
return strings.Contains(lower, "forbidden.no.auth") ||
strings.Contains(lower, "forbidden.accessdenied") ||
strings.Contains(text, "需要您具备") || strings.Contains(text, "及以上角色")
}
// isDocumentPermissionError reports whether a permission-denied body is
// specific to document/wiki access, where applying for access via
// dws drive permission apply-* is the actionable next step.
func isDocumentPermissionError(body map[string]any) bool {
for _, key := range []string{"code", "errorCode", "server_error_code"} {
if code, ok := body[key].(string); ok && documentPermissionServerCodes[code] {
return true
}
}
return documentPermissionErrorText(businessErrorMessage(body))
}
// genericPermissionSuggestion is the product-neutral suggestion for
// permission-denied errors that are not specific to document/wiki access.
const genericPermissionSuggestion = "Verify your account has permission for this resource"
// permissionDeniedSuggestion picks between document apply-permission guidance
// and a product-neutral hint: only document/wiki-specific errors point at
// dws drive permission apply-*, so other products never get a misleading
// document-permission suggestion.
func permissionDeniedSuggestion(text string) string {
if documentPermissionErrorText(text) {
return permissionApplyGuidance
}
return genericPermissionSuggestion
}
// permissionDeniedSuggestionFor resolves the suggestion for a permission-denied
// body: document/wiki-specific errors get the apply-permission guidance; other
// products keep their product-specific suggestion (e.g. the mail mailbox hint)
// or fall back to the product-neutral hint.
func permissionDeniedSuggestionFor(body map[string]any) string {
if isDocumentPermissionError(body) {
return permissionApplyGuidance
}
if suggestion := suggestForBusinessError(body); suggestion != "" {
return suggestion
}
return genericPermissionSuggestion
}
// ClassifyToolResultContent checks a raw MCP tool result content map for
// DWS gateway auth errors and PAT permission error codes. This is used as the
// edition.Hooks.ClassifyToolResult callback so the framework's runner returns
// a typed error before its generic business-error classification.
//
// Check order matches ClassifyMCPResponseText: DWS gateway > PAT permission.
// Check order matches ClassifyMCPResponseText: DWS gateway > PAT permission >
// no-permission guidance.
func ClassifyToolResultContent(content map[string]any) error {
if _, ok := getDWSGatewayErrorCode(content); ok {
raw, _ := json.Marshal(content)
@@ -408,6 +512,20 @@ func ClassifyToolResultContent(content map[string]any) error {
return &PATError{RawJSON: cleanPATJSON(content, code)}
}
}
// Permission-denied business errors: surface apply-permission guidance before
// the framework's generic rendering swallows it. Guidance is limited to
// document/wiki-specific signals (forbidden.* codes or role-threshold
// wording); other products keep the product-neutral suggestion.
if isNoPermissionError(content) {
suggestion := permissionDeniedSuggestionFor(content)
raw, _ := json.Marshal(content)
return &CLIError{
Code: CodeAuthPermission,
Message: businessErrorDisplayMessage(content, string(raw)),
Suggestion: suggestion,
}
}
return nil
}
@@ -419,6 +537,23 @@ var patNoPermissionCodes = map[string]bool{
"PAT_HIGH_RISK_NO_PERMISSION": true,
}
// noPermissionServerCodes are business-level permission-denied codes (from the
// MCP server / gateway) that should surface apply-permission guidance instead of
// being swallowed by the framework's generic business-error rendering.
var noPermissionServerCodes = map[string]bool{
"NO_PERMISSION": true,
"FORBIDDEN": true,
"forbidden.no.auth": true,
"forbidden.accessDenied": true,
}
// permissionApplySteps lists the two-step commands to apply for document access.
const permissionApplySteps = " 1) 查可申请角色与审批人: dws drive permission apply-info --node <节点ID或URL>\n" +
" 2) 选定角色与审批人后发起申请: dws drive permission apply --node <节点ID或URL> --role <EDITOR|DOWNLOADER|READER> --users <审批人userId>"
// permissionApplyGuidance is the standard suggestion for permission-denied errors.
const permissionApplyGuidance = "若你对该文档/知识库暂无访问权限,可发起权限申请:\n" + permissionApplySteps
// ClassifyMCPResponseText classifies a text response returned by an MCP tool call.
// Returns a typed error for known gateway auth failures, PAT interceptions,
// and business-level errors embedded in HTTP-200 JSON bodies.
@@ -452,10 +587,18 @@ func ClassifyMCPResponseText(text string) error {
}
}
if isNoPermissionError(body) {
return &CLIError{
Code: CodeAuthPermission,
Message: text,
Suggestion: permissionDeniedSuggestionFor(body),
}
}
if isBusinessError(body) {
return &CLIError{
Code: CodeMCPToolError,
Message: text,
Message: businessErrorDisplayMessage(body, text),
Suggestion: suggestForBusinessError(body),
}
}
+164 -8
View File
@@ -123,10 +123,19 @@ func TestCrossPlatformCoverageBusinessSuggestionsAndResponseClassification(t *te
"User has no permission to access this email": "mailbox list",
"频率超限": "rate limit",
"rate limit": "rate limit",
"用户不存在": "--members",
"不属于当前组织": "--members",
"成员不存在": "--members",
"未找到指定工具": "未注册",
"MCP不存在": "未注册",
"参数错误": "input parameters",
"param error": "input parameters",
// 非文档类权限文本不再给出 drive permission apply 指引。
"无权限访问": "Verify your account",
"权限不足": "Verify your account",
// 权限关键词叠加文档特征(文档权限码/角色门槛)时保留 apply 指引。
"您没有权限访问该文档 (forbidden.no.auth)": "权限申请",
"无权限访问:需要您具备 MANAGER 及以上角色": "权限申请",
}
for input, want := range suggestions {
if got := suggestForBusinessErrorText(input); !strings.Contains(got, want) {
@@ -152,17 +161,76 @@ func TestCrossPlatformCoverageBusinessSuggestionsAndResponseClassification(t *te
t.Errorf("PAT key %s classified as %#v", key, err)
}
}
// forbidden.accessDenied(新接口权限不足错误码)应分类为 AUTH_PERMISSION_DENIED
// 并携带权限申请指引,而不是被 generic business-error 渲染吞掉。
for _, key := range []string{"code", "errorCode", "server_error_code"} {
err := ClassifyToolResultContent(map[string]any{key: "forbidden.accessDenied", "success": false, "errorMsg": "需要您具备 MANAGER 及以上角色"})
cli, ok := err.(*CLIError)
if !ok || cli.Code != CodeAuthPermission {
t.Errorf("accessDenied key %s classified as %#v, want AUTH_PERMISSION_DENIED", key, err)
continue
}
if !strings.Contains(cli.Message, "需要您具备") || !strings.Contains(cli.Suggestion, "permission apply") {
t.Errorf("accessDenied guidance missing: message=%q suggestion=%q", cli.Message, cli.Suggestion)
}
}
// 带 forbidden.* 域名的权限码是 drive 专属,单独出现即可拿到 apply 指引。
for _, code := range []string{"forbidden.no.auth"} {
err := ClassifyToolResultContent(map[string]any{"code": code, "success": false, "errorMsg": "no permission"})
cli, ok := err.(*CLIError)
if !ok || cli.Code != CodeAuthPermission || !strings.Contains(cli.Suggestion, "permission apply") {
t.Errorf("document code %s classified as %#v, want AUTH_PERMISSION_DENIED with apply guidance", code, err)
}
}
// 通用码名 NO_PERMISSION 不再单独触发文档 apply 指引(回归测试):
// 考勤 get-self-setting、事件订阅等非文档工具也会返回该码。
nonDoc := ClassifyToolResultContent(map[string]any{"code": "NO_PERMISSION", "success": false, "errorMsg": "no permission"})
if cli, ok := nonDoc.(*CLIError); !ok || cli.Code != CodeAuthPermission ||
!strings.Contains(cli.Suggestion, "Verify your account") || strings.Contains(cli.Suggestion, "permission apply") {
t.Errorf("NO_PERMISSION classified as %#v, want product-neutral suggestion without apply guidance", nonDoc)
}
// NO_PERMISSION 叠加文档特征文本(角色门槛)时仍保留 apply 指引。
docCombo := ClassifyToolResultContent(map[string]any{"code": "NO_PERMISSION", "success": false, "errorMsg": "需要您具备 MANAGER 及以上角色"})
if cli, ok := docCombo.(*CLIError); !ok || cli.Code != CodeAuthPermission || !strings.Contains(cli.Suggestion, "permission apply") {
t.Errorf("NO_PERMISSION with document wording classified as %#v, want apply guidance", docCombo)
}
// 非文档产品的权限错误保留产品专属建议(邮件)或通用提示,
// 不得被改写为 drive 文档权限申请指引。
mail := ClassifyToolResultContent(map[string]any{"success": false, "errorMsg": "User has no permission to access this email"})
if cli, ok := mail.(*CLIError); !ok || cli.Code != CodeAuthPermission ||
!strings.Contains(cli.Suggestion, "mailbox list") || strings.Contains(cli.Suggestion, "permission apply") {
t.Errorf("mail permission error classified as %#v, want mailbox hint without apply guidance", mail)
}
generic := ClassifyToolResultContent(map[string]any{"success": false, "code": "FORBIDDEN", "errorMsg": "群权限不足"})
if cli, ok := generic.(*CLIError); !ok || cli.Code != CodeAuthPermission ||
!strings.Contains(cli.Suggestion, "Verify your account") || strings.Contains(cli.Suggestion, "permission apply") {
t.Errorf("generic permission error classified as %#v, want product-neutral suggestion", generic)
}
for _, tc := range []struct {
text string
kind string
text string
kind string
suggHas string
}{
{"not-json", "nil"},
{`{"errorCode":"USER_TOKEN_ILLEGAL"}`, "cli"},
{`{"error":"Missing service_id or access_key"}`, "cli"},
{`{"code":"PAT_HIGH_RISK_NO_PERMISSION","extra":{"class":"x","keep":1}}`, "pat"},
{`{"success":false,"errorMsg":"rate limit"}`, "cli"},
{`{"success":true}`, "nil"},
{"not-json", "nil", ""},
{"null", "nil", ""},
{`{"errorCode":"USER_TOKEN_ILLEGAL"}`, "cli", ""},
{`{"error":"Missing service_id or access_key"}`, "cli", ""},
{`{"code":"PAT_HIGH_RISK_NO_PERMISSION","extra":{"class":"x","keep":1}}`, "pat", ""},
{`{"success":false,"errorMsg":"rate limit"}`, "cli", ""},
{`{"success":false,"code":"forbidden.accessDenied","errorMsg":"需要您具备 MANAGER 及以上角色","logId":"abc123"}`, "authperm", "permission apply"},
// 非文档产品的权限错误:分类为 AUTH_PERMISSION_DENIED,但建议保持
// 产品专属(邮件 mailbox)或通用提示,不带文档 apply 指引。
{`{"success":false,"errorMsg":"User has no permission to access this email"}`, "authperm", "mailbox list"},
{`{"success":false,"code":"FORBIDDEN","errorMsg":"群权限不足"}`, "authperm", "Verify your account"},
{`{"success":false,"code":"FORBIDDEN"}`, "authperm", "Verify your account"},
{`{"success":false,"code":"NO_PERMISSION","errorMsg":"no permission"}`, "authperm", "Verify your account"},
// 文档/知识库角色门槛文案与文档权限码文本:apply 指引保留。
{`{"success":false,"errorMsg":"需要您具备 MANAGER 及以上角色"}`, "authperm", "permission apply"},
{`{"success":false,"errorMsg":"forbidden.no.auth: 你无权访问该文档"}`, "authperm", "permission apply"},
{`{"success":false,"code":"NO_PERMISSION","errorMsg":"需要您具备 MANAGER 及以上角色"}`, "authperm", "permission apply"},
{`{"success":false,"errorMsg":"members[0].id 对应的用户不存在"}`, "cli", ""},
{`{"success":true}`, "nil", ""},
} {
err := ClassifyMCPResponseText(tc.text)
switch tc.kind {
@@ -174,6 +242,15 @@ func TestCrossPlatformCoverageBusinessSuggestionsAndResponseClassification(t *te
if _, ok := err.(*CLIError); !ok {
t.Errorf("ClassifyMCPResponseText(%s) = %#v", tc.text, err)
}
case "authperm":
cli, ok := err.(*CLIError)
if !ok || cli.Code != CodeAuthPermission {
t.Errorf("ClassifyMCPResponseText(%s) = %#v, want AUTH_PERMISSION_DENIED", tc.text, err)
continue
}
if !strings.Contains(cli.Suggestion, tc.suggHas) {
t.Errorf("ClassifyMCPResponseText(%s) suggestion = %q, want it to contain %q", tc.text, cli.Suggestion, tc.suggHas)
}
case "pat":
if _, ok := err.(*PATError); !ok {
t.Errorf("ClassifyMCPResponseText(%s) = %#v", tc.text, err)
@@ -235,3 +312,82 @@ func TestCrossPlatformCoveragePATCleanupAndMatchingHelpers(t *testing.T) {
t.Fatalf("stripClassFields slice = %s", got)
}
}
// TestCrossPlatformCoverageBusinessErrorMessagePriority 同步自内部 MR 28965577:
// errorMessage 是新接口返回的用户/成员校验错误的字段,优先级介于 errorMsg 与 message 之间。
func TestCrossPlatformCoverageBusinessErrorMessagePriority(t *testing.T) {
if msg := businessErrorMessage(map[string]any{"errorMsg": "first", "errorMessage": "second", "message": "third", "error": "fourth"}); msg != "first" {
t.Errorf("businessErrorMessage = %q, want %q (errorMsg has priority)", msg, "first")
}
want := "members[0].id 对应的用户不存在"
if msg := businessErrorMessage(map[string]any{"errorMessage": want, "message": "third", "error": "fourth"}); msg != want {
t.Errorf("businessErrorMessage = %q, want %q (errorMessage fallback)", msg, want)
}
if msg := businessErrorMessage(map[string]any{"message": "second", "error": "third"}); msg != "second" {
t.Errorf("businessErrorMessage = %q, want %q (message fallback)", msg, "second")
}
if msg := businessErrorMessage(map[string]any{}); msg != "" {
t.Errorf("businessErrorMessage(empty) = %q, want empty", msg)
}
}
// TestCrossPlatformCoverageBusinessErrorDisplayMessage 验证 code/logId 附加以便排查。
func TestCrossPlatformCoverageBusinessErrorDisplayMessage(t *testing.T) {
got := businessErrorDisplayMessage(map[string]any{"errorMessage": "权限不足", "logId": "abc123"}, "raw")
if got != "权限不足 (logId: abc123)" {
t.Errorf("businessErrorDisplayMessage = %q, want logId appended", got)
}
// errorCode/code 存在时附加在后,保证后端错误码可见
got = businessErrorDisplayMessage(map[string]any{"errorMessage": "失败", "errorCode": "forbidden.document.sizeOverLimit"}, "raw")
if got != "失败 (code: forbidden.document.sizeOverLimit)" {
t.Errorf("businessErrorDisplayMessage = %q, want code appended", got)
}
got = businessErrorDisplayMessage(map[string]any{"message": "失败", "code": "forbidden.accessDenied", "logId": "l1"}, "raw")
if got != "失败 (code: forbidden.accessDenied, logId: l1)" {
t.Errorf("businessErrorDisplayMessage = %q, want code+logId appended", got)
}
// logId 已包含在 message 中时不重复追加
got = businessErrorDisplayMessage(map[string]any{"errorMessage": "失败 logId:abc123", "logId": "abc123"}, "raw")
if got != "失败 logId:abc123" {
t.Errorf("businessErrorDisplayMessage = %q, want no duplicate logId", got)
}
// 无任何 message 字段时回退 rawText
if got := businessErrorDisplayMessage(map[string]any{"success": false}, "raw-fallback"); got != "raw-fallback" {
t.Errorf("businessErrorDisplayMessage = %q, want rawText fallback", got)
}
}
// TestCrossPlatformCoverageUserNotInOrgErrors 验证「用户不存在/不属于当前组织/成员不存在」
// 在 RESOURCE_NOT_FOUND 之前被拦截为 MCP_TOOL_ERROR 并携带 --members corpId 建议。
func TestCrossPlatformCoverageUserNotInOrgErrors(t *testing.T) {
suggestion := suggestForBusinessError(map[string]any{"errorMessage": "members[0].id 对应的用户不存在或不属于当前组织(实际值:209499),请检查后重试。"})
if !strings.Contains(suggestion, "--members") {
t.Errorf("suggestForBusinessError should mention --members for user/org error, got: %q", suggestion)
}
for _, msg := range []string{
"用户不存在",
"不属于当前组织",
"成员不存在",
} {
cli, ok := WrapErrorWithOperation(errors.New(msg), "wiki/add_member").(*CLIError)
if !ok {
t.Fatalf("WrapErrorWithOperation(%q) not a CLIError", msg)
}
if cli.Code != CodeMCPToolError {
t.Errorf("WrapErrorWithOperation(%q).Code = %s, want %s (not RESOURCE_NOT_FOUND)", msg, cli.Code, CodeMCPToolError)
}
if !strings.Contains(cli.Suggestion, "corpId") {
t.Errorf("WrapErrorWithOperation(%q).Suggestion should mention corpId, got: %q", msg, cli.Suggestion)
}
}
// JSON 体形式:透传 errorMessage 且附 logId
cli, ok := WrapErrorWithOperation(errors.New(`{"errorMessage":"用户不存在","logId":"lid-1"}`), "wiki/add_member").(*CLIError)
if !ok {
t.Fatal("WrapErrorWithOperation(json) not a CLIError")
}
if cli.Message != "用户不存在 (logId: lid-1)" {
t.Errorf("WrapErrorWithOperation(json).Message = %q, want errorMessage + logId", cli.Message)
}
}
+89 -10
View File
@@ -255,7 +255,11 @@ func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResul
}
if isBusinessError(errBody) {
return "", &CLIError{
Code: CodeMCPToolError,
Code: CodeMCPToolError,
// 注意:这里必须保留原始响应 JSON。此路径是数据编排层
// (如 drive list --depth 的限流重试)的输入,下游会从
// Message 反解析 errorCode;人话提取(含 code/logId 附加)
// 只用于 callMCPToolInternalOptsContext 的终端展示路径。
Message: c.Text,
Suggestion: suggestForBusinessError(errBody),
}
@@ -440,6 +444,20 @@ func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolN
// 尝试将返回文本解析为 JSON,进行错误分类
var errBody map[string]any
if json.Unmarshal([]byte(c.Text), &errBody) == nil {
// errBody == nil 表示文本为 "null",服务端返回了空响应。
// 仅对已确认“空响应=写成功”契约的 permission/member update/remove
// 工具适配为空对象 {},避免消费方解析 null 时出错;其它工具的
// 合法 null 保持原样输出,不改变未版本化的机器输出契约。
if errBody == nil {
if nullOnSuccessTools[toolName] {
if deps.Caller.Format() == "json" {
return printJSON(map[string]any{})
}
deps.Out.PrintRaw("{}")
return nil
}
return renderLegacyMCPText(toolName, c.Text, unescapeHTML)
}
// 网关层错误(如 token 过期)
if _, ok := getDWSGatewayErrorCode(errBody); ok {
return &CLIError{Code: CodeAuthTokenExpired, Message: c.Text, Suggestion: authExpiredSuggestion()}
@@ -454,7 +472,7 @@ func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolN
}
// 业务逻辑错误
if isBusinessError(errBody) {
return &CLIError{Code: CodeMCPToolError, Message: c.Text, Suggestion: suggestForBusinessError(errBody)}
return &CLIError{Code: CodeMCPToolError, Message: businessErrorDisplayMessage(errBody, c.Text), Suggestion: suggestForBusinessError(errBody)}
}
}
@@ -465,6 +483,18 @@ func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolN
return printJSON(result)
}
// nullOnSuccessTools lists MCP tools whose server contract is confirmed to
// return a literal JSON null for successful no-payload writes (permission /
// member update/remove). Only these get the null→{} adaptation; every other
// tool keeps its raw null output so the shared machine-output contract stays
// unchanged.
var nullOnSuccessTools = map[string]bool{
"update_permission": true,
"remove_permission": true,
"update_member": true,
"remove_member": true,
}
// RenderLegacyMCPText renders an already-fetched MCP text response through the
// exact legacy formatter. It lets dual validation execute the business request
// once, validate a shadow unified result, and still preserve legacy bytes.
@@ -765,15 +795,64 @@ func getDWSGatewayErrorCode(errBody map[string]any) (string, bool) {
// suggestForBusinessError returns a user-facing suggestion for known business
// error patterns in a parsed JSON body, or "" if no specific suggestion applies.
func suggestForBusinessError(body map[string]any) string {
msg := ""
if v, ok := body["errorMsg"].(string); ok {
msg = v
} else if v, ok := body["message"].(string); ok {
msg = v
} else if v, ok := body["error"].(string); ok {
msg = v
return suggestForBusinessErrorText(businessErrorMessage(body))
}
// businessErrorMessage extracts the human-readable message from a parsed error
// body, checking errorMsg > errorMessage > message > error. Returns "" if none present.
func businessErrorMessage(body map[string]any) string {
for _, k := range []string{"errorMsg", "errorMessage", "message", "error"} {
if v, ok := body[k].(string); ok && v != "" {
return v
}
}
return suggestForBusinessErrorText(msg)
return ""
}
// businessErrorDisplayMessage extracts the human-readable message from a parsed
// error body, appends the backend error code and logId if present (for
// traceability), and falls back to rawText if no message field is found.
func businessErrorDisplayMessage(body map[string]any, rawText string) string {
msg := businessErrorMessage(body)
if msg == "" {
return rawText
}
var extras []string
for _, k := range []string{"errorCode", "error_code", "code"} {
if code, ok := body[k].(string); ok && code != "" && !strings.Contains(msg, code) {
extras = append(extras, "code: "+code)
break
}
}
if logId, ok := body["logId"].(string); ok && logId != "" && !strings.Contains(msg, logId) {
extras = append(extras, "logId: "+logId)
}
if len(extras) > 0 {
msg = msg + " (" + strings.Join(extras, ", ") + ")"
}
return msg
}
// isNoPermissionError reports whether a parsed error body represents a
// permission-denied error, by known server codes or message text. Used to
// surface apply-permission guidance before the framework's generic rendering.
func isNoPermissionError(body map[string]any) bool {
for _, key := range []string{"code", "errorCode", "server_error_code"} {
if code, ok := body[key].(string); ok && noPermissionServerCodes[code] {
return true
}
}
msg := businessErrorMessage(body)
if msg == "" {
return false
}
lower := strings.ToLower(msg)
return strings.Contains(msg, "无权限访问") || strings.Contains(msg, "没有访问权限") ||
strings.Contains(msg, "没有权限") || strings.Contains(msg, "权限不足") ||
strings.Contains(lower, "no permission") || strings.Contains(lower, "permission denied") ||
strings.Contains(lower, "forbidden.no.auth") ||
strings.Contains(lower, "forbidden.accessdenied") ||
strings.Contains(msg, "需要您具备") || strings.Contains(msg, "及以上角色")
}
// confirmDelete is a convenience wrapper around cmdutil.ConfirmDelete that
+3 -3
View File
@@ -147,9 +147,9 @@ func TestCrossPlatformCoverageRunDocReadJsonMLCoverage(t *testing.T) {
`{"jsonml":"{\"type\":\"doc\"}","revision":"bad"}`,
} {
caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}
_ = runDocReadJsonML(nil, "node", "")
_ = runDocReadJsonML("node", "", "", 0, false)
}
caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"jsonml":"{\"type\":\"doc\"}"}`}}}
_ = runDocReadJsonML(nil, "node", filepath.Join(t.TempDir(), "out.json"))
_ = runDocReadJsonML(nil, "node", filepath.Join(t.TempDir(), "missing", "out.json"))
_ = runDocReadJsonML("node", filepath.Join(t.TempDir(), "out.json"), "", 0, false)
_ = runDocReadJsonML("node", filepath.Join(t.TempDir(), "missing", "out.json"), "pw", 3, true)
}
+525 -5
View File
@@ -2,12 +2,16 @@ package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
@@ -17,6 +21,9 @@ import (
"github.com/spf13/cobra"
)
// computeFileMD5 is the function used to calculate file MD5. Tests can override via direct assignment.
var computeFileMD5 = fileMD5Hex
func decodeOARequest(raw string) (map[string]any, error) {
dec := json.NewDecoder(bytes.NewBufferString(raw))
dec.UseNumber()
@@ -125,7 +132,13 @@ func validateOAPreviewFileIDs(cmd *cobra.Command, _ []string) error {
}
func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) {
data, err := CallMCPToolDataOnServer(cmd.Context(), "oa", tool, args)
return callOAAttachmentResultCtx(cmd.Context(), tool, args)
}
// callOAAttachmentResultCtx 复用统一结果投影逻辑,但允许调用方传入自定义 context
// (例如 upload 命令的 10 分钟超时):调用 oa/<tool>,提取 result 并包装为 output.Success。
func callOAAttachmentResultCtx(ctx context.Context, tool string, args map[string]any) (output.CommandResult, error) {
data, err := CallMCPToolDataOnServer(ctx, "oa", tool, args)
if err != nil {
return nil, err
}
@@ -140,10 +153,225 @@ func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any
return output.Success(result), nil
}
// validateOAAttachmentCommitResult 校验 commit_attachment_upload_info 的原始 result
// 包含构造 DDAttachment 所需的全部必需字段,并将 spaceId/fileSize 归一化为声明的 integer
// 类型(int64),确保输出始终符合 ResultSpec schema。当字段缺失或类型错误时返回 Validation
// 错误,避免 Agent 拿到空 fileId 后组装无效的审批表单。
func validateOAAttachmentCommitResult(result any) (map[string]any, error) {
resultMap, ok := result.(map[string]any)
if !ok || resultMap == nil {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值 result 不是有效的 JSON 对象")
}
// spaceId — 接受 string 或 number,归一化为 int64
switch v := resultMap["spaceId"].(type) {
case string:
if strings.TrimSpace(v) == "" {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 spaceId")
}
parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 spaceId 不是有效整数")
}
resultMap["spaceId"] = parsed
case float64:
// numeric spaceId — keep as-is (already matches JSON number)
case json.Number:
parsed, err := v.Int64()
if err != nil {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 spaceId 不是有效整数")
}
resultMap["spaceId"] = parsed
default:
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 spaceId")
}
// fileName — must be non-empty string
if s, ok := resultMap["fileName"].(string); !ok || strings.TrimSpace(s) == "" {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileName")
}
// fileSize — must be > 0 (number),归一化 json.Number → int64
switch v := resultMap["fileSize"].(type) {
case float64:
if v <= 0 {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 fileSize 必须大于 0")
}
case json.Number:
parsed, err := v.Int64()
if err != nil || parsed <= 0 {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 fileSize 必须大于 0")
}
resultMap["fileSize"] = parsed
default:
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileSize")
}
// fileId — must be non-empty string
if s, ok := resultMap["fileId"].(string); !ok || strings.TrimSpace(s) == "" {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileId")
}
return resultMap, nil
}
// parseOAAttachmentUploadInfo 解析 oa/init_attachment_upload_info 的返回,提取首个上传地址、
// 签名请求头与 uploadKey。OA 的返回结构为 result.resourceUrls([]string)、result.headers(object)、
// result.uploadKey(string),与钉盘 doc 的 resourceUrl(单数)不同,因此单独实现。
func parseOAAttachmentUploadInfo(text string) (resourceURL string, headers map[string]string, uploadKey string, err error) {
var data map[string]any
if err = json.Unmarshal([]byte(text), &data); err != nil {
return "", nil, "", apperrors.NewInternal(fmt.Sprintf("解析 init_attachment_upload_info 返回失败: %v", err))
}
result, ok := data["result"].(map[string]any)
if !ok {
return "", nil, "", apperrors.NewInternal("oa/init_attachment_upload_info 返回值缺少 result")
}
uploadKey, _ = result["uploadKey"].(string)
if strings.TrimSpace(uploadKey) == "" {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 uploadKey")
}
rawURLs, ok := result["resourceUrls"].([]any)
if !ok || len(rawURLs) == 0 {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 resourceUrls")
}
resourceURL, _ = rawURLs[0].(string)
if strings.TrimSpace(resourceURL) == "" {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 首个 resourceUrls 为空")
}
rawHeaders, ok := result["headers"].(map[string]any)
if !ok {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 headers")
}
headers = make(map[string]string)
for key, value := range rawHeaders {
if str, ok := value.(string); ok {
headers[key] = str
}
}
if strings.TrimSpace(headers["Authorization"]) == "" || strings.TrimSpace(headers["x-oss-date"]) == "" {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info headers 缺少必需的签名字段 Authorization 或 x-oss-date")
}
return resourceURL, headers, uploadKey, nil
}
// runOAAttachmentUpload 端到端上传审批附件:init 获取 OSS 上传凭证 → HTTP PUT 上传文件字节
// → commit 提交入库,一条命令完成三步。PUT 复用包级 httpPutFile(它会删除 Content-Type 并
// 写入签名请求头,避免钉钉 OSS SignatureDoesNotMatch),不要在此重复实现。
func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error {
filePath := strings.TrimSpace(mustGetFlag(cmd, "file"))
if filePath == "" {
return apperrors.NewValidation("--file 不能为空")
}
info, err := os.Stat(filePath)
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("无法读取文件 %s: %v", filePath, err))
}
if info.IsDir() {
return apperrors.NewValidation(fmt.Sprintf("%s 是目录,不是文件", filePath))
}
fileSize := info.Size()
fileName := strings.TrimSpace(mustGetFlag(cmd, "file-name"))
if fileName == "" {
fileName = filepath.Base(filePath)
}
md5Hex := strings.TrimSpace(mustGetFlag(cmd, "md5"))
if md5Hex == "" {
md5Hex, err = computeFileMD5(filePath)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("计算文件 MD5 失败: %v", err))
}
}
// --dry-run:本地只读工作(校验、Stat、大小、文件名、MD5)已完成,
// 在任何远程调用(init/PUT/commit)之前 early return,输出 plan 预览。
// 本命令是 RolloutUnifiedActive,必须经 output.StoreResult 存入统一结果,
// 不能直接 PrintJSON(否则框架报 "returned without a CommandResult")。
// 不伪造 uploadKey/resourceURL/fileId/spaceId——它们只能由远程调用返回。
if deps.Caller.DryRun() {
return output.StoreResult(cmd.Context(), output.Success(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"operation": "attachment_upload",
"source": "oa",
"file": filePath,
"file_name": fileName,
"file_size": fileSize,
"md5": md5Hex,
"steps": []map[string]any{
{
"tool": "oa/init_attachment_upload_info",
"args": map[string]any{"fileName": fileName, "fileSize": fileSize, "md5": md5Hex},
"status": "planned",
},
{
"tool": "HTTP PUT",
"args": map[string]any{"file": filePath, "fileSize": fileSize},
"status": "planned",
},
{
"tool": "oa/commit_attachment_upload_info",
"args": map[string]any{"fileName": fileName, "fileSize": fileSize},
"requires": []string{"uploadKey from oa/init_attachment_upload_info"},
"status": "planned",
},
},
}, output.WithDryRun()))
}
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
defer cancel()
// Step 1: 初始化上传,拿到 OSS 上传地址、签名头与 uploadKey。
initText, err := callMCPToolReturnTextOnServer(ctx, "oa", "init_attachment_upload_info", map[string]any{
"fileName": fileName,
"fileSize": float64(fileSize),
"md5": md5Hex,
})
if err != nil {
return err
}
resourceURL, headers, uploadKey, err := parseOAAttachmentUploadInfo(initText)
if err != nil {
return err
}
// Step 2: HTTP PUT 文件字节到 OSS(复用 httpPutFile,勿重复实现)。
if err := httpPutFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
return err
}
// Step 3: 提交上传信息完成入库,校验必需字段后以统一输出渲染 commit 结果。
commitData, err := CallMCPToolDataOnServer(ctx, "oa", "commit_attachment_upload_info", map[string]any{
"fileName": fileName,
"uploadKey": uploadKey,
"fileSize": float64(fileSize),
})
if err != nil {
return err
}
commitResp, ok := commitData.(map[string]any)
if !ok {
return apperrors.NewInternal("oa/commit_attachment_upload_info 返回值不是 JSON 对象")
}
commitResult, _ := commitResp["result"]
normalized, err := validateOAAttachmentCommitResult(commitResult)
if err != nil {
return err
}
return output.StoreResult(cmd.Context(), output.Success(normalized))
}
func newOAAttachmentCommand() *cobra.Command {
attachmentCmd := &cobra.Command{
Use: "attachment",
Short: "审批附件授权与下载链接",
Short: "审批附件授权、上传、下载与链接管理",
RunE: groupRunE,
}
@@ -318,10 +546,156 @@ func newOAAttachmentCommand() *cobra.Command {
},
})
attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd)
uploadCmd := &cobra.Command{
Use: "upload",
Short: "上传本地文件为审批附件(初始化+PUT+提交,一步完成)",
Long: `上传本地文件为审批附件(三步自动完成)。
流程:
1. 初始化上传信息,获取 OSS 上传地址与凭证 (oa/init_attachment_upload_info)
2. HTTP PUT 上传文件二进制到 OSS
3. 提交上传信息完成入库 (oa/commit_attachment_upload_info)
--file-name 不传时默认用文件名;--md5 不传时自动计算。`,
Example: ` dws oa approval attachment upload --file ./合同.pdf
dws oa approval attachment upload --file ./report.xlsx --file-name Q1报表.xlsx
dws oa approval attachment upload --file ./data.bin --md5 d41d8cd98f00b204e9800998ecf8427e`,
RunE: runOAAttachmentUpload,
}
DeclareLeafMetadata(uploadCmd, LeafSpec{
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{
Effect: "write", Risk: "low",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "attachment_upload",
CanonicalPath: "oa.attachment_upload",
CLIPath: "oa approval attachment upload",
PrimaryCLIPath: "oa approval attachment upload",
},
Description: "上传本地文件为审批附件,一条命令完成初始化、HTTP PUT 与提交入库",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"审批附件上传提交结果","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"fileName":{"type":"string","description":"文件名"},"fileSize":{"type":"integer","description":"文件字节数"},"class":{"type":"string","description":"服务端响应类型标识"},"fileType":{"type":"string","description":"文件类型"},"fileId":{"type":"string","description":"文件 ID"}},"required":["spaceId","fileName","fileSize","fileId"],"additionalProperties":true}`),
},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令包含多个 RPC(init/commit)与本地 HTTP PUT 步骤,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "上传本地文件为审批附件,自动完成初始化+PUT+提交三步",
UseWhen: []string{"用户要把本地文件作为审批附件上传(首选一条命令自动完成凭证+PUT+提交)时"},
AvoidWhen: []string{
"仅需为已有附件授权下载或预览时使用 attachment authorize-download / authorize-preview",
"仅需获取已有附件临时下载链接时使用 attachment download-url",
},
Examples: []string{"dws oa approval attachment upload --file ./合同.pdf --format json"},
},
},
})
uploadCmd.Flags().String("file", "", "本地文件路径 (必填)")
uploadCmd.Flags().String("file-name", "", "完整文件名,例如 合同.pdf (默认使用文件名)")
uploadCmd.Flags().String("md5", "", "文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算)")
_ = uploadCmd.MarkFlagRequired("file")
attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd, uploadCmd)
return attachmentCmd
}
// oaAdminQueryMaxPageSize is the pageSize upper bound of
// get_process_instances_by_admin.
const oaAdminQueryMaxPageSize = float64(20)
// validateOARequestProcessCode checks the processCode field of a decoded
// --request payload: the tool requires it, and the backend answers a bad
// processCode with success:true and an empty list, so reject it client-side.
func validateOARequestProcessCode(request map[string]any) error {
v, ok := request["processCode"]
if !ok {
return fmt.Errorf("--request 缺少必填字段 processCode")
}
s, ok := v.(string)
if !ok || s == "" {
return fmt.Errorf("--request processCode 必须为非空字符串")
}
return nil
}
// validateOARequestPageSize checks the pageSize field of a decoded
// --request payload (json.Number values from decodeOARequest).
func validateOARequestPageSize(request map[string]any) error {
v, ok := request["pageSize"]
if !ok {
return nil
}
n, ok := v.(json.Number)
if !ok {
return fmt.Errorf("pageSize 必须为数字")
}
f, err := n.Float64()
if err != nil || f < 1 || f > oaAdminQueryMaxPageSize {
return fmt.Errorf("pageSize 必须在 1-%d 之间,got: %s", int(oaAdminQueryMaxPageSize), n.String())
}
return nil
}
// oaAdminTimeLayout is the startTime/endTime wire format of
// get_process_instances_by_admin since the 2026-08 MCP contract update
// (both fields changed from epoch millis to strings).
const oaAdminTimeLayout = "2006-01-02 15:04:05"
// oaAdminTimeLayoutHint is the user-facing spelling of the layout used in
// error messages.
const oaAdminTimeLayoutHint = "yyyy-MM-dd HH:mm:ss"
var oaAdminTimeZone = time.FixedZone("Asia/Shanghai", 8*3600)
// formatOAAdminQueryTime renders a millisecond timestamp into the
// yyyy-MM-dd HH:mm:ss string required by get_process_instances_by_admin.
func formatOAAdminQueryTime(ms int64) string {
return time.UnixMilli(ms).In(oaAdminTimeZone).Format(oaAdminTimeLayout)
}
// validateOARequestTimeRange checks startTime/endTime of a decoded
// --request payload: startTime is required, both must be
// yyyy-MM-dd HH:mm:ss strings, and endTime must be strictly after
// startTime (matching simple mode's ValidateTimeRange).
func validateOARequestTimeRange(request map[string]any) error {
startRaw, ok := request["startTime"]
if !ok {
return fmt.Errorf("--request 缺少必填字段 startTime")
}
startStr, ok := startRaw.(string)
if !ok {
return fmt.Errorf("startTime 必须为 %s 格式字符串", oaAdminTimeLayoutHint)
}
start, err := time.ParseInLocation(oaAdminTimeLayout, startStr, oaAdminTimeZone)
if err != nil {
return fmt.Errorf("startTime 必须为 %s 格式,got: %s", oaAdminTimeLayoutHint, startStr)
}
endRaw, ok := request["endTime"]
if !ok {
return nil
}
endStr, ok := endRaw.(string)
if !ok {
return fmt.Errorf("endTime 必须为 %s 格式字符串", oaAdminTimeLayoutHint)
}
end, err := time.ParseInLocation(oaAdminTimeLayout, endStr, oaAdminTimeZone)
if err != nil {
return fmt.Errorf("endTime 必须为 %s 格式,got: %s", oaAdminTimeLayoutHint, endStr)
}
if !end.After(start) {
return fmt.Errorf("--request endTime 必须晚于 startTime")
}
return nil
}
// ──────────────────────────────────────────────────────────
// dws oa — OA 审批
// MCP tools(tools/list): list_pending_approvals, get_processInstance_detail,
@@ -329,8 +703,9 @@ func newOAAttachmentCommand() *cobra.Command {
// get_processInstance_records, list_initiated_instances, list_pending_tasks,
// list_user_visible_process, append_task, search_form, oa_ding_user, revert_task,
// get_inst_revert_activities, get_process_schema, forecast_process,
// start_process_instance, get_attachment_download_url, auth_download_file,
// auth_preview_attachment
// start_process_instance, get_process_instances_by_admin,
// get_attachment_download_url, auth_download_file,
// auth_preview_attachment, init_attachment_upload_info, commit_attachment_upload_info
// ──────────────────────────────────────────────────────────
func newOaCommand() *cobra.Command {
@@ -1432,6 +1807,129 @@ func newOaCommand() *cobra.Command {
},
},
})
// 以管理员身份查询审批实例列表
listByAdminSimpleFlags := []string{"process-code", "start", "end", "cursor", "limit", "user-ids", "statuses"}
approvalListByAdminCmd := &cobra.Command{
Use: "list-by-admin", Short: "以管理员身份查询审批模板的实例列表",
Example: ` dws oa approval list-by-admin --process-code <code> --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20
dws oa approval list-by-admin --request '{"processCode":"PROC-xxx","startTime":"2026-03-10 00:00:00","cursor":0,"pageSize":20,"statuses":["RUNNING"]}'`,
PreRunE: func(cmd *cobra.Command, args []string) error {
// Cobra 的 flag group 校验(英文报错)在 PreRunE 之后执行,
// 这里先校验同一组约束,保证用户看到的是中文错误。
request, _ := cmd.Flags().GetString("request")
processCode, _ := cmd.Flags().GetString("process-code")
if request == "" && processCode == "" {
return fmt.Errorf("--request、--process-code 至少指定一个")
}
if request != "" {
for _, name := range listByAdminSimpleFlags {
if cmd.Flags().Changed(name) {
return fmt.Errorf("--request 与 --%s 不能同时指定", name)
}
}
}
if processCode != "" && !cmd.Flags().Changed("start") {
return fmt.Errorf("--process-code、--start 必须同时指定(缺少 --start)")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if raw, _ := cmd.Flags().GetString("request"); raw != "" {
request, err := decodeOARequest(raw)
if err != nil {
return fmt.Errorf("--request JSON 解析失败: %w", err)
}
if err := validateOARequestProcessCode(request); err != nil {
return err
}
if err := validateOARequestPageSize(request); err != nil {
return err
}
if err := validateOARequestTimeRange(request); err != nil {
return err
}
return callMCPTool("get_process_instances_by_admin", map[string]any{"ProcessInstanceListQueryRequest": request})
}
if err := validateRequiredFlags(cmd, "process-code", "start"); err != nil {
return err
}
startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start"))
if err != nil {
return err
}
cursor, err := strconv.ParseFloat(mustGetFlag(cmd, "cursor"), 64)
if err != nil {
return fmt.Errorf("--cursor 必须为数字: %w", err)
}
pageSize, err := strconv.ParseFloat(mustGetFlag(cmd, "limit"), 64)
if err != nil {
return fmt.Errorf("--limit 必须为数字: %w", err)
}
if pageSize < 1 || pageSize > oaAdminQueryMaxPageSize {
return fmt.Errorf("--limit 必须在 1-%d 之间,got: %s", int(oaAdminQueryMaxPageSize), mustGetFlag(cmd, "limit"))
}
request := map[string]any{
"processCode": mustGetFlag(cmd, "process-code"),
"startTime": formatOAAdminQueryTime(startMs),
"cursor": cursor,
"pageSize": pageSize,
}
if v, _ := cmd.Flags().GetString("end"); v != "" {
endMs, err := parseISOTimeToMillis("end", v)
if err != nil {
return err
}
if err := validateTimeRange(startMs, endMs); err != nil {
return err
}
request["endTime"] = formatOAAdminQueryTime(endMs)
}
if v, _ := cmd.Flags().GetString("user-ids"); v != "" {
request["userIds"] = strings.Split(v, ",")
}
if v, _ := cmd.Flags().GetString("statuses"); v != "" {
request["statuses"] = strings.Split(v, ",")
}
return callMCPTool("get_process_instances_by_admin", map[string]any{"ProcessInstanceListQueryRequest": request})
},
}
DeclareLeafMetadata(approvalListByAdminCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "get_process_instances_by_admin",
CanonicalPath: "oa.get_process_instances_by_admin",
CLIPath: "oa approval list-by-admin",
PrimaryCLIPath: "oa approval list-by-admin",
},
Description: "以管理员身份获取审批单列表",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "get_process_instances_by_admin"},
},
Selection: contract.SelectionSpec{
AgentSummary: "以管理员身份按模板、时间范围、状态与用户查询企业内审批实例列表",
UseWhen: []string{"具备 OA 审批管理员权限,需要跨用户统计或检索某模板下的审批实例时"},
AvoidWhen: []string{
"只查自己的待办/已办/已发起/抄送时改用 list-pending / list-executed / list-initiated / list-cc",
"无 OA 管理员权限时不要使用,该命令查不到数据",
},
Examples: []string{
"dws oa approval list-by-admin --process-code <code> --start \"2026-03-10T00:00:00+08:00\" --cursor 0 --limit 20",
"dws oa approval list-by-admin --process-code <code> --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --statuses RUNNING,COMPLETED --user-ids \"userId1,userId2\"",
},
},
// Simple-mode flags are mapping exclusions (encoded inside ProcessInstanceListQueryRequest).
Parameters: []contract.ParamDecl{
{Name: "request", Property: "ProcessInstanceListQueryRequest", InterfaceType: "object"},
},
},
})
approvalCreateCmd := &cobra.Command{
Use: "create-instance", Short: "发起审批实例(需要 --yes 确认)",
Example: "dws oa approval create-instance --process-code <processCode> --form-values '{\"事由\":\"测试\"}' --yes",
@@ -1610,6 +2108,27 @@ func newOaCommand() *cobra.Command {
RequireTogether: [][]string{{"process-code", "dept-id", "form-values"}},
})
approvalListByAdminCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)")
approvalListByAdminCmd.Flags().String("start", "", "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)(简单模式使用;与 --request 互斥)")
approvalListByAdminCmd.Flags().String("end", "", "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)(可选)")
approvalListByAdminCmd.Flags().String("cursor", "0", "分页游标,首次传 0")
approvalListByAdminCmd.Flags().String("limit", "20", "每页大小,最大 20")
approvalListByAdminCmd.Flags().String("user-ids", "", "按发起人 userId 过滤,多个用逗号分隔(可选)")
approvalListByAdminCmd.Flags().String("statuses", "", "按审批状态过滤,多个用逗号分隔(可选,如 RUNNING、TERMINATED、COMPLETED)")
approvalListByAdminCmd.Flags().String("request", "", "完整请求 JSON(高级模式;与简单模式参数互斥)")
approvalListByAdminCmd.MarkFlagsOneRequired("request", "process-code")
approvalListByAdminCmd.MarkFlagsRequiredTogether("process-code", "start")
listByAdminMutuallyExclusive := make([][]string, 0, len(listByAdminSimpleFlags))
for _, name := range listByAdminSimpleFlags {
approvalListByAdminCmd.MarkFlagsMutuallyExclusive("request", name)
listByAdminMutuallyExclusive = append(listByAdminMutuallyExclusive, []string{"request", name})
}
cli.AnnotateRuntimeConstraints(approvalListByAdminCmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: listByAdminMutuallyExclusive,
RequireOneOf: [][]string{{"request", "process-code"}},
RequireTogether: [][]string{{"process-code", "start"}},
})
approvalCreateCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)")
approvalCreateCmd.Flags().String("dept-id", "-1", "发起人部门 ID")
approvalCreateCmd.Flags().String("form-values", "", "表单值 JSON(简单模式使用;与 --request 互斥)")
@@ -1656,6 +2175,7 @@ func newOaCommand() *cobra.Command {
approvalRevertTaskCmd,
approvalFormSchemaCmd,
approvalForecastCmd,
approvalListByAdminCmd,
approvalCreateCmd,
)
approvalCmd.AddCommand(newOAAttachmentCommand())
+846
View File
@@ -10,7 +10,9 @@ import (
"errors"
"io"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
@@ -30,6 +32,7 @@ func executeOAAttachmentCommandCapturingOutput(t *testing.T, caller *scriptedToo
cmd := newOaCommand()
cmd.PersistentFlags().Bool("yes", false, "跳过确认")
cmd.PersistentFlags().Bool("dry-run", false, "仅预览不执行")
cmd.PersistentFlags().String("format", caller.Format(), "输出格式")
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
@@ -337,3 +340,846 @@ func TestCrossPlatformCoverageOAAttachmentRejectsInvalidPreviewFileIDs(t *testin
})
}
}
const oaUploadInitResponse = `{"result":{"uploadKey":"key-123","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z","Authorization":"OSS signature"},"storageDriver":"oss"},"success":true}`
func oaUploadCommitResponse(size int64) string {
return `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":` +
strconv.FormatInt(size, 10) +
`,"class":"com.dingtalk.oapi.response","fileType":"pdf","fileId":"file-abc"},"success":true}`
}
// writeOAAttachmentTempFile 落地一个临时文件,返回其绝对路径与字节数。
func writeOAAttachmentTempFile(t *testing.T, name, content string) (string, int64) {
t.Helper()
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
return path, int64(len(content))
}
// capturedPut 记录一次 httpPutFile 调用的入参,供断言 PUT 使用了解析出的
// resourceURL 与签名 headers。
type capturedPut struct {
calls int
url string
headers map[string]string
filePath string
fileSize int64
}
func mockOAAttachmentPut(t *testing.T) *capturedPut {
t.Helper()
put := &capturedPut{}
SetHTTPPutFile(func(_ context.Context, url string, headers map[string]string, filePath string, fileSize int64) error {
put.calls++
put.url = url
put.headers = headers
put.filePath = filePath
put.fileSize = fileSize
return nil
})
t.Cleanup(func() { SetHTTPPutFile(nil) })
return put
}
func TestCrossPlatformCoverageOAAttachmentUploadEndToEnd(t *testing.T) {
const content = "pdf-bytes-content"
filePath, fileSize := writeOAAttachmentTempFile(t, "source.pdf", content)
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{text: oaUploadCommitResponse(fileSize)},
}}
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "合同.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err != nil {
t.Fatalf("execute command: %v", err)
}
if caller.calls != 2 {
t.Fatalf("MCP calls = %d, want 2 (init+commit)", caller.calls)
}
if caller.toolLog[0] != "init_attachment_upload_info" || caller.serverLog[0] != "oa" {
t.Fatalf("first call = %s/%s, want oa/init_attachment_upload_info", caller.serverLog[0], caller.toolLog[0])
}
wantInit := map[string]any{
"fileName": "合同.pdf",
"fileSize": float64(fileSize),
"md5": "d41d8cd98f00b204e9800998ecf8427e",
}
if !reflect.DeepEqual(caller.argsLog[0], wantInit) {
t.Fatalf("init args = %#v, want %#v", caller.argsLog[0], wantInit)
}
if put.calls != 1 {
t.Fatalf("httpPutFile called %d times, want 1", put.calls)
}
if put.url != "https://oss.example.test/upload" {
t.Fatalf("PUT url = %q, want parsed resourceURL", put.url)
}
if put.headers["Authorization"] != "OSS signature" || put.headers["x-oss-date"] != "20260820T000000Z" {
t.Fatalf("PUT headers = %#v, want parsed signed headers", put.headers)
}
if put.filePath != filePath || put.fileSize != fileSize {
t.Fatalf("PUT file = %q size = %d, want %q/%d", put.filePath, put.fileSize, filePath, fileSize)
}
if caller.toolLog[1] != "commit_attachment_upload_info" {
t.Fatalf("second call = %s, want commit_attachment_upload_info", caller.toolLog[1])
}
wantCommit := map[string]any{
"fileName": "合同.pdf",
"uploadKey": "key-123",
"fileSize": float64(fileSize),
}
if !reflect.DeepEqual(caller.argsLog[1], wantCommit) {
t.Fatalf("commit args = %#v, want %#v", caller.argsLog[1], wantCommit)
}
var envelope map[string]any
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
t.Fatalf("decode unified output: %v", err)
}
if envelope["ok"] != true || envelope["outcome"] != "success" {
t.Fatalf("unified envelope = %#v", envelope)
}
data, ok := envelope["data"].(map[string]any)
if !ok || data["fileId"] != "file-abc" {
t.Fatalf("unified data = %#v, want fileId file-abc", envelope["data"])
}
}
func TestCrossPlatformCoverageOAAttachmentUploadAutoMD5AndDefaultName(t *testing.T) {
const content = "auto-md5-content"
filePath, fileSize := writeOAAttachmentTempFile(t, "report.xlsx", content)
wantMD5, err := fileMD5Hex(filePath)
if err != nil {
t.Fatalf("compute expected md5: %v", err)
}
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{text: oaUploadCommitResponse(fileSize)},
}}
// 既不传 --file-name 也不传 --md5:文件名应回退为 basename,md5 应自动计算。
if _, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
); err != nil {
t.Fatalf("execute command: %v", err)
}
if got := caller.argsLog[0]["fileName"]; got != "report.xlsx" {
t.Fatalf("default fileName = %#v, want basename report.xlsx", got)
}
if got := caller.argsLog[0]["md5"]; got != wantMD5 {
t.Fatalf("auto md5 = %#v, want computed %q", got, wantMD5)
}
if got := caller.argsLog[0]["fileSize"]; got != float64(fileSize) {
t.Fatalf("fileSize = %#v, want number %d", got, fileSize)
}
}
func TestCrossPlatformCoverageOAAttachmentUploadMalformedInitResponse(t *testing.T) {
tests := []struct {
name string
response string
wantErr string
}{
{
name: "missing result",
response: `{"success":true}`,
wantErr: "缺少 result",
},
{
name: "missing uploadKey",
response: `{"result":{"resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
wantErr: "缺少 uploadKey",
},
{
name: "empty uploadKey",
response: `{"result":{"uploadKey":" ","resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
wantErr: "缺少 uploadKey",
},
{
name: "empty resourceUrls",
response: `{"result":{"uploadKey":"key-1","resourceUrls":[],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
wantErr: "缺少 resourceUrls",
},
{
name: "empty resourceUrls element",
response: `{"result":{"uploadKey":"key-1","resourceUrls":[""],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
wantErr: "首个 resourceUrls 为空",
},
{
name: "missing headers",
response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"]},"success":true}`,
wantErr: "缺少 headers",
},
{
name: "headers missing Authorization",
response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z"}},"success":true}`,
wantErr: "Authorization",
},
{
name: "headers missing x-oss-date",
response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"OSS sig"}},"success":true}`,
wantErr: "x-oss-date",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "test.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: test.response},
// second step should never be reached
{text: `{"result":{},"success":true}`},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "test.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil {
t.Fatalf("expected error containing %q, got nil", test.wantErr)
}
if !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
if caller.calls != 1 {
t.Fatalf("MCP calls = %d, want 1 (init only, no commit)", caller.calls)
}
if put.calls != 0 {
t.Fatalf("httpPutFile called %d times, want 0 (should not PUT)", put.calls)
}
})
}
}
func TestCrossPlatformCoverageOAAttachmentUploadRequiredFlagValidation(t *testing.T) {
existing, _ := writeOAAttachmentTempFile(t, "exists.bin", "x")
tests := []struct {
name string
args []string
}{
{name: "missing file", args: []string{"approval", "attachment", "upload"}},
{name: "file does not exist", args: []string{"approval", "attachment", "upload", "--file", filepath.Join(existing, "missing.bin")}},
{name: "file is directory", args: []string{"approval", "attachment", "upload", "--file", filepath.Dir(existing)}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{}
err := executeOACommand(t, caller, test.args...)
if err == nil {
t.Fatalf("%s unexpectedly succeeded", test.name)
}
if caller.calls != 0 {
t.Fatalf("invalid upload made %d MCP call(s)", caller.calls)
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentUploadDryRunDoesNotCallRemoteOrPut 验证
// --dry-run 在任何远程调用(init/PUT/commit)之前 early return:零 MCP 调用、
// 零 PUT,且输出一个包含 3 个 planned step 的 plan 预览。
func TestCrossPlatformCoverageOAAttachmentUploadDryRunDoesNotCallRemoteOrPut(t *testing.T) {
const content = "dry-run-preview-content"
filePath, fileSize := writeOAAttachmentTempFile(t, "plan.pdf", content)
wantMD5, err := fileMD5Hex(filePath)
if err != nil {
t.Fatalf("compute expected md5: %v", err)
}
put := mockOAAttachmentPut(t)
// caller.dry 驱动 deps.Caller.DryRun()==true;同时传入 --dry-run 以镜像生产路径。
caller := &scriptedToolCaller{format: "json", dry: true}
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--dry-run",
)
if err != nil {
t.Fatalf("execute command: %v", err)
}
if caller.calls != 0 {
t.Fatalf("dry-run made %d MCP call(s), want 0", caller.calls)
}
if put.calls != 0 {
t.Fatalf("dry-run made %d PUT call(s), want 0", put.calls)
}
var envelope map[string]any
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
t.Fatalf("decode unified output: %v\noutput: %s", err, stdout)
}
data, ok := envelope["data"].(map[string]any)
if !ok {
t.Fatalf("unified data missing/invalid: %#v", envelope["data"])
}
if data["dry_run"] != true || data["executed"] != false || data["preview_kind"] != "plan" {
t.Fatalf("plan flags = %#v", data)
}
if data["operation"] != "attachment_upload" || data["source"] != "oa" {
t.Fatalf("plan operation/source = %#v", data)
}
if data["file_name"] != "plan.pdf" || data["file_size"] != float64(fileSize) || data["md5"] != wantMD5 {
t.Fatalf("plan file metadata = %#v (want file_name=plan.pdf size=%d md5=%s)", data, fileSize, wantMD5)
}
steps, ok := data["steps"].([]any)
if !ok || len(steps) != 3 {
t.Fatalf("plan steps = %#v, want 3 planned steps", data["steps"])
}
wantTools := []string{"oa/init_attachment_upload_info", "HTTP PUT", "oa/commit_attachment_upload_info"}
for i, raw := range steps {
step, ok := raw.(map[string]any)
if !ok {
t.Fatalf("step %d not an object: %#v", i, raw)
}
if step["tool"] != wantTools[i] {
t.Fatalf("step %d tool = %#v, want %q", i, step["tool"], wantTools[i])
}
if step["status"] != "planned" {
t.Fatalf("step %d status = %#v, want planned", i, step["status"])
}
}
// The commit step (index 2) must NOT contain uploadKey in args (remote-only field)
// and must declare the dependency via a "requires" entry.
commitStep := steps[2].(map[string]any)
commitArgs, _ := commitStep["args"].(map[string]any)
if _, hasUploadKey := commitArgs["uploadKey"]; hasUploadKey {
t.Fatalf("commit step args must not contain uploadKey, got: %#v", commitArgs)
}
requires, ok := commitStep["requires"].([]any)
if !ok || len(requires) == 0 {
t.Fatalf("commit step must have non-empty requires, got: %#v", commitStep["requires"])
}
found := false
for _, r := range requires {
if s, ok := r.(string); ok && (len(s) > 0 && strings.Contains(s, "uploadKey") && strings.Contains(s, "init")) {
found = true
break
}
}
if !found {
t.Fatalf("commit step requires should mention uploadKey and init, got: %#v", requires)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadMD5Failure injects a failing computeFileMD5 to
// cover runOAAttachmentUpload's MD5 failure branch on all platforms (including Windows).
func TestCrossPlatformCoverageOAAttachmentUploadMD5Failure(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "test.bin", "hello")
put := mockOAAttachmentPut(t)
testseam.Swap(t, &computeFileMD5, func(string) (string, error) {
return "", errors.New("permission denied")
})
caller := &scriptedToolCaller{format: "json"}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
)
if err == nil {
t.Fatal("expected error from MD5 failure, got nil")
}
if !strings.Contains(err.Error(), "MD5") {
t.Fatalf("error should mention MD5, got: %v", err)
}
if caller.calls != 0 {
t.Fatalf("expected 0 MCP calls when MD5 fails, got %d", caller.calls)
}
if put.calls != 0 {
t.Fatalf("expected 0 PUT calls when MD5 fails, got %d", put.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadHTTPPutError 注入一个返回错误的 PUT,
// 覆盖 runOAAttachmentUpload 中 httpPutFile 失败分支:init 后报错(1 次 MCP 调用)且不提交。
func TestCrossPlatformCoverageOAAttachmentUploadHTTPPutError(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "put-fail.pdf", "data")
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error {
return errors.New("oss put failed")
})
t.Cleanup(func() { SetHTTPPutFile(nil) })
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
// commit 不应被达到
{text: `{"result":{},"success":true}`},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "put-fail.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil || !strings.Contains(err.Error(), "oss put failed") {
t.Fatalf("error = %v, want oss put failed", err)
}
if caller.calls != 1 {
t.Fatalf("MCP calls = %d, want 1 (init only, no commit after PUT failure)", caller.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadMalformedCommitResponse 校验 commit 返回
// 缺少必需字段时命令报错(而不是包装为成功)。
func TestCrossPlatformCoverageOAAttachmentUploadMalformedCommitResponse(t *testing.T) {
tests := []struct {
name string
commitResponse string
wantErr string
}{
{
name: "result is null",
commitResponse: `{"success":true,"result":null}`,
wantErr: "不是有效的 JSON 对象",
},
{
name: "result is empty object",
commitResponse: `{"success":true,"result":{}}`,
wantErr: "spaceId",
},
{
name: "missing fileId",
commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":100}}`,
wantErr: "fileId",
},
{
name: "empty fileId",
commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":100,"fileId":""}}`,
wantErr: "fileId",
},
{
name: "missing spaceId",
commitResponse: `{"success":true,"result":{"fileName":"a.pdf","fileSize":100,"fileId":"file-1"}}`,
wantErr: "spaceId",
},
{
name: "fileSize is zero",
commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":0,"fileId":"file-1"}}`,
wantErr: "fileSize",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "commit-validate.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{text: test.commitResponse},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "commit-validate.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil {
t.Fatalf("expected error containing %q, got nil", test.wantErr)
}
if !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
// init + commit = 2 MCP calls
if caller.calls != 2 {
t.Fatalf("MCP calls = %d, want 2 (init + commit)", caller.calls)
}
// PUT should have been called (init succeeded)
if put.calls != 1 {
t.Fatalf("httpPutFile called %d times, want 1", put.calls)
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentValidateCommitResultDirect 直接调用
// validateOAAttachmentCommitResult 覆盖所有分支路径,包括通过 end-to-end 流不可达的
// json.Number 类型分支。
func TestCrossPlatformCoverageOAAttachmentValidateCommitResultDirect(t *testing.T) {
tests := []struct {
name string
input any
wantErr string // empty means expect nil error
}{
// result 不是 map
{name: "result is string", input: "hello", wantErr: "不是有效的 JSON 对象"},
{name: "result is array", input: []any{"x"}, wantErr: "不是有效的 JSON 对象"},
{name: "result is number", input: float64(42), wantErr: "不是有效的 JSON 对象"},
{name: "result is nil", input: nil, wantErr: "不是有效的 JSON 对象"},
// spaceId — 空字符串
{name: "spaceId empty string", input: map[string]any{
"spaceId": "", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "spaceId"},
{name: "spaceId whitespace only", input: map[string]any{
"spaceId": " ", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "spaceId"},
// spaceId — json.Number(通过 UseNumber 解码的数字)
{name: "spaceId json.Number", input: map[string]any{
"spaceId": json.Number("27827223951"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: ""},
// spaceId — float64(正常路径)
{name: "spaceId float64", input: map[string]any{
"spaceId": float64(123), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: ""},
// spaceId — 非法类型
{name: "spaceId bool", input: map[string]any{
"spaceId": true, "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "spaceId"},
// fileName — 非 string 类型
{name: "fileName is number", input: map[string]any{
"spaceId": "123", "fileName": float64(99), "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "fileName"},
{name: "fileName is nil", input: map[string]any{
"spaceId": "123", "fileName": nil, "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "fileName"},
{name: "fileName empty", input: map[string]any{
"spaceId": "123", "fileName": "", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "fileName"},
// fileSize — json.Number 有效
{name: "fileSize json.Number valid", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("200"), "fileId": "file-1",
}, wantErr: ""},
// fileSize — json.Number 无效(<= 0)
{name: "fileSize json.Number zero", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("0"), "fileId": "file-1",
}, wantErr: "fileSize"},
{name: "fileSize json.Number negative", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("-5"), "fileId": "file-1",
}, wantErr: "fileSize"},
{name: "fileSize json.Number invalid", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("abc"), "fileId": "file-1",
}, wantErr: "fileSize"},
// fileSize — 非法类型(default 分支)
{name: "fileSize is string", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": "100", "fileId": "file-1",
}, wantErr: "fileSize"},
{name: "fileSize is nil", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": nil, "fileId": "file-1",
}, wantErr: "fileSize"},
// fileId — 非 string 类型
{name: "fileId is number", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": float64(100), "fileId": float64(99),
}, wantErr: "fileId"},
{name: "fileId whitespace", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": float64(100), "fileId": " ",
}, wantErr: "fileId"},
// 全部通过
{name: "all valid string types", input: map[string]any{
"spaceId": "123", "fileName": "report.pdf", "fileSize": float64(512), "fileId": "file-abc",
}, wantErr: ""},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := validateOAAttachmentCommitResult(test.input)
if test.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q, got nil", test.wantErr)
}
if !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentUploadEmptyFilePath 覆盖 runOAAttachmentUpload
// 中 --file 传了但值为空字符串的分支(cobra MarkFlagRequired 只拦截未传,不拦截空值)。
func TestCrossPlatformCoverageOAAttachmentUploadEmptyFilePath(t *testing.T) {
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json"}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", "",
)
if err == nil || !strings.Contains(err.Error(), "--file 不能为空") {
t.Fatalf("error = %v, want --file empty error", err)
}
if caller.calls != 0 {
t.Fatalf("MCP calls = %d, want 0", caller.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadInitCallError 覆盖 runOAAttachmentUpload
// 中 callMCPToolReturnTextOnServer(init 步)返回错误的分支。
func TestCrossPlatformCoverageOAAttachmentUploadInitCallError(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "init-err.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{err: errors.New("injected init failure")},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "init-err.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil {
t.Fatal("expected error from init call failure, got nil")
}
if caller.calls != 1 {
t.Fatalf("MCP calls = %d, want 1 (init attempted)", caller.calls)
}
if put.calls != 0 {
t.Fatalf("PUT calls = %d, want 0", put.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadCommitNonObject 覆盖 runOAAttachmentUpload
// 中 commitData 不是 map[string]any 的分支(line 348)。
func TestCrossPlatformCoverageOAAttachmentUploadCommitNonObject(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "commit-nonobj.pdf", "data")
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
// commit 返回一个 JSON 字符串而非对象
{text: `"not an object"`},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "commit-nonobj.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil || !strings.Contains(err.Error(), "不是 JSON 对象") {
t.Fatalf("error = %v, want commit non-object error", err)
}
if caller.calls != 2 {
t.Fatalf("MCP calls = %d, want 2 (init + commit)", caller.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadMalformedInitJSON 覆盖
// parseOAAttachmentUploadInfo 中 json.Unmarshal 失败分支(init 返回非法 JSON)。
func TestCrossPlatformCoverageOAAttachmentUploadMalformedInitJSON(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "bad-init.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{not valid json`},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "bad-init.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil || !strings.Contains(err.Error(), "解析 init_attachment_upload_info 返回失败") {
t.Fatalf("error = %v, want parse failure", err)
}
if caller.calls != 1 {
t.Fatalf("MCP calls = %d, want 1 (init only)", caller.calls)
}
if put.calls != 0 {
t.Fatalf("PUT calls = %d, want 0", put.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentValidateCommitResultNormalization 验证
// validateOAAttachmentCommitResult 对 spaceId/fileSize 的归一化行为。
func TestCrossPlatformCoverageOAAttachmentValidateCommitResultNormalization(t *testing.T) {
tests := []struct {
name string
input map[string]any
wantSpaceID any
wantSize any
wantErr string
}{
{
name: "string spaceId normalized to int64",
input: map[string]any{"spaceId": "27827223951", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantSpaceID: int64(27827223951),
wantSize: float64(100),
},
{
name: "non-numeric string spaceId returns error",
input: map[string]any{"spaceId": "abc", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantErr: "spaceId",
},
{
name: "float string spaceId returns error",
input: map[string]any{"spaceId": "12.5", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantErr: "spaceId",
},
{
name: "json.Number spaceId normalized to int64",
input: map[string]any{"spaceId": json.Number("27827223951"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantSpaceID: int64(27827223951),
wantSize: float64(100),
},
{
name: "json.Number spaceId non-integer returns error",
input: map[string]any{"spaceId": json.Number("12.5"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantErr: "spaceId",
},
{
name: "json.Number fileSize normalized to int64",
input: map[string]any{"spaceId": float64(123), "fileName": "a.pdf", "fileSize": json.Number("2048"), "fileId": "file-1"},
wantSpaceID: float64(123),
wantSize: int64(2048),
},
{
name: "float64 spaceId unchanged",
input: map[string]any{"spaceId": float64(27827223951), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantSpaceID: float64(27827223951),
wantSize: float64(100),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
normalized, err := validateOAAttachmentCommitResult(test.input)
if test.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", test.wantErr)
}
if !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if normalized["spaceId"] != test.wantSpaceID {
t.Fatalf("spaceId = %v (%T), want %v (%T)", normalized["spaceId"], normalized["spaceId"], test.wantSpaceID, test.wantSpaceID)
}
if normalized["fileSize"] != test.wantSize {
t.Fatalf("fileSize = %v (%T), want %v (%T)", normalized["fileSize"], normalized["fileSize"], test.wantSize, test.wantSize)
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentUploadOutputContract 验证端到端上传命令
// 输出 JSON 中 spaceId 始终为 number(即使 commit 返回字符串 spaceId)。
func TestCrossPlatformCoverageOAAttachmentUploadOutputContract(t *testing.T) {
tests := []struct {
name string
commitResponse string
wantSpaceID float64 // JSON decode 后 number → float64
}{
{
name: "spaceId as number",
commitResponse: `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":17,"fileType":"pdf","fileId":"file-abc"},"success":true}`,
wantSpaceID: float64(27827223951),
},
{
name: "spaceId as string",
commitResponse: `{"result":{"spaceId":"27827223951","fileName":"合同.pdf","fileSize":17,"fileType":"pdf","fileId":"file-abc"},"success":true}`,
wantSpaceID: float64(27827223951),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
const content = "output-contract-data"
filePath, _ := writeOAAttachmentTempFile(t, "contract.pdf", content)
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{text: test.commitResponse},
}}
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "合同.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err != nil {
t.Fatalf("execute command: %v", err)
}
var envelope map[string]any
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
t.Fatalf("decode unified output: %v", err)
}
if envelope["ok"] != true || envelope["outcome"] != "success" {
t.Fatalf("unified envelope = %#v", envelope)
}
data, ok := envelope["data"].(map[string]any)
if !ok {
t.Fatalf("unified data missing: %#v", envelope)
}
// spaceId must be a number in the JSON output
spaceID, ok := data["spaceId"].(float64)
if !ok {
t.Fatalf("spaceId is not a number: %v (%T)", data["spaceId"], data["spaceId"])
}
if spaceID != test.wantSpaceID {
t.Fatalf("spaceId = %v, want %v", spaceID, test.wantSpaceID)
}
// fileSize must also be a number
if _, ok := data["fileSize"].(float64); !ok {
t.Fatalf("fileSize is not a number: %v (%T)", data["fileSize"], data["fileSize"])
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentUploadCommitError 脚本化 init 返回合法、
// commit 返回错误,覆盖 callOAAttachmentResultCtx(commit 步)失败分支:
// PUT 发生一次,init+commit 均尝试(共 2 次 MCP 调用),命令报错。
func TestCrossPlatformCoverageOAAttachmentUploadCommitError(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "commit-fail.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{err: errors.New("commit upload failed")},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "commit-fail.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil || !strings.Contains(err.Error(), "commit upload failed") {
t.Fatalf("error = %v, want commit upload failed", err)
}
if put.calls != 1 {
t.Fatalf("PUT calls = %d, want 1", put.calls)
}
if caller.calls != 2 {
t.Fatalf("MCP calls = %d, want 2 (init + commit attempted)", caller.calls)
}
if caller.toolLog[0] != "init_attachment_upload_info" || caller.toolLog[1] != "commit_attachment_upload_info" {
t.Fatalf("tool sequence = %v, want init then commit", caller.toolLog)
}
}

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