From 01476727e9dfd59afd25fb47f31d41aea3a84b79 Mon Sep 17 00:00:00 2001 From: Dennis Date: Mon, 24 Aug 2026 20:38:47 +0800 Subject: [PATCH] feat(shortcut): harden DevApp and Agoal task surfaces --- docs/shortcut-public-catalog.json | 251 ++- internal/app/schema_shortcut_contract_test.go | 6 +- internal/shortcut/agoal/agoal.go | 750 +++++++++ ...anged_line_cross_platform_coverage_test.go | 201 +++ .../shortcut/agoal/agoal_hardening_test.go | 434 +++++ internal/shortcut/builtin/builtin.go | 1 + internal/shortcut/devapp/devapp.go | 1440 +++++++++++++---- ...anged_line_cross_platform_coverage_test.go | 742 +++++++++ .../shortcut/devapp/devapp_hardening_test.go | 527 ++++++ .../devapp_pagination_projection_test.go | 26 +- .../shortcut/devapp/devapp_rollout_test.go | 12 +- internal/shortcut/public_catalog_generated.go | 11 + internal/shortcut/runner.go | 17 + internal/shortcut/runner_coverage_test.go | 51 + internal/shortcut/semantic_catalog.go | 8 + internal/shortcut/semantic_catalog_agoal.json | 23 + .../shortcut/semantic_catalog_devapp.json | 37 + ...ev-devapp-agoal-shortcut-dual-live-gate.sh | 798 +++++++++ scripts/gen_shortcut_public_catalog.py | 2 + skills/mono/SKILL.md | 3 +- .../multi/dingtalk-misc/references/devapp.md | 8 +- 21 files changed, 5015 insertions(+), 333 deletions(-) create mode 100644 internal/shortcut/agoal/agoal.go create mode 100644 internal/shortcut/agoal/agoal_changed_line_cross_platform_coverage_test.go create mode 100644 internal/shortcut/agoal/agoal_hardening_test.go create mode 100644 internal/shortcut/devapp/devapp_changed_line_cross_platform_coverage_test.go create mode 100644 internal/shortcut/devapp/devapp_hardening_test.go create mode 100644 internal/shortcut/semantic_catalog_agoal.json create mode 100644 internal/shortcut/semantic_catalog_devapp.json create mode 100755 scripts/dev/dev-devapp-agoal-shortcut-dual-live-gate.sh diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 36e1beca..3a93cdfb 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,7 +1,57 @@ { - "generated_at": "2026-08-24T12:22:05.108140", - "count": 426, + "generated_at": "2026-08-24T20:14:49.172788", + "count": 437, "results": [ + { + "suite": "semantic", + "service": "agoal", + "command": "+contract-fields", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、字段数组和每项稳定 id;本地 keyword 覆盖字段标识、编码、标题、分类和类型,支持已知非空与保证零命中。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "agoal", + "command": "+obj-template-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result 数组、稳定模板 ID 及 page/pageSize/totalCount;不虚构 cursor。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "agoal", + "command": "+report-statistics-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、content 数组和每项稳定 templateId;关键词同时支持已知非空与合法零命中。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "agoal", + "command": "+report-submit-detail", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、嵌套稳定用户身份及 page/pageSize/totalCount;下游忽略 keyword,因此做有界全量遍历、本地过滤、停滞/重复/总数变化失败与人员字段最小投影。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "agoal", + "command": "+user-rules", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、content 对象、rules 数组和稳定 ruleId;本地精确 ruleId 选择器支持已知非空与保证零命中。", + "availability": "available" + }, { "suite": "semantic", "service": "aisearch", @@ -2473,137 +2523,254 @@ "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+create", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "创建后提取稳定 unifiedAppId,并以同一 ID 读回名称及请求字段;只有精确核验通过才返回成功。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", + "service": "devapp", + "command": "+credentials-get", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格要求 success、稳定 unifiedAppId、非空客户端标识和非空 secret;Result 将密钥路径声明为敏感。", + "availability": "available" + }, + { + "suite": "semantic", "service": "devapp", "command": "+delete", "risk": "high-risk-write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "删除前读回稳定 appKey,删除后按该选择器有界遍历全部页并证明同一 unifiedAppId 不再存在。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+disable", - "risk": "write", - "status": "real-ok" + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "停用后按同一 unifiedAppId 读回并要求 appStatus=disabled。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+enable", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "启用后按同一 unifiedAppId 读回并要求 appStatus=normal。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+event-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、事件数组、每项稳定 eventCode 与游标终止证据;明确返回可用事件目录及订阅状态,并拒绝坏元素与伪空结果。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "devapp", + "command": "+event-subscribe", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "拒绝空值与重复 eventCode,写后有界遍历订阅列表并逐项精确读回。", + "availability": "available" + }, + { + "suite": "semantic", "service": "devapp", "command": "+get", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "拒绝空响应、缺失 success 和空业务对象,并要求读回 unifiedAppId 与请求精确一致。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、应用数组、稳定 unifiedAppId 与游标终止证据;投影当前页并保留可续翻 meta.pagination。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+member-add", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "拒绝空值和重复 userId,校验写终态后按稳定 userId 逐项精确读回,并要求 memberType 与请求角色一致。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+member-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、成员数组和每项稳定 userId;新增本地精确 userId 选择器以证明已知非空与保证零命中。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+member-remove", - "risk": "write", - "status": "real-ok" + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "拒绝空值和重复 userId,校验写终态后读取完整成员数组,并逐项证明目标稳定 userId 已不存在。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+permission-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、权限数组、每项稳定 scopeValue 与游标终止证据,拒绝坏元素和伪空结果。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "devapp", + "command": "+robot-config", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "拒绝空配置,写后按同一 unifiedAppId 读取机器人对象并精确比较全部请求标量字段。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "devapp", + "command": "+robot-disable", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "要求写终态成功并读回 robotStatus=UNCONFIGURED;不虚构保留配置或可直接恢复语义。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "devapp", + "command": "+robot-enable", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "要求写终态成功并按同一 unifiedAppId 读回 robotStatus=ONLINE。", + "availability": "available" + }, + { + "suite": "semantic", "service": "devapp", "command": "+robot-get", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证非空机器人配置对象,并要求读回 unifiedAppId 与请求精确一致。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+update", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "要求至少一个更新字段,写入后按同一 unifiedAppId 精确读回所有请求字段。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+version-check-approval", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "只执行 precheckOnly,严格绑定应用与版本身份,并保留可执行后续动作的 pending 结果。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "devapp", + "command": "+version-create", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "创建回执必须含稳定 versionId,随后以 unifiedAppId/versionId 双身份读取详情并核验请求字段;live fixture 通过删除临时父应用清理。", + "availability": "available" + }, + { + "suite": "semantic", "service": "devapp", "command": "+version-get", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证非空版本对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+version-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、版本数组、每项稳定 versionId 与游标终止证据。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+version-status", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证非空发布状态对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+webapp-config", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "拒绝空更新,写入后按同一 unifiedAppId 读取网页配置并精确比较全部请求字段。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+webapp-get", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证非空网页配置对象,并要求读回 unifiedAppId 与请求精确一致。", + "availability": "available" }, { "suite": "semantic", diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 882a8113..13452541 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 425 + publicShortcutCount = 436 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including reviewed hidden compatibility and unavailable contracts. - schemaPublishedShortcutCount = 482 + schemaPublishedShortcutCount = 493 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 425 + publiclyDeliveredShortcutCount = 436 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { diff --git a/internal/shortcut/agoal/agoal.go b/internal/shortcut/agoal/agoal.go new file mode 100644 index 00000000..1d258cbd --- /dev/null +++ b/internal/shortcut/agoal/agoal.go @@ -0,0 +1,750 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package agoal declares reviewed shortcuts for DingTalk Agoal. The public +// surface intentionally stays smaller than the atomic helper surface: only +// reads with strict response contracts and safe live proof are available. +package agoal + +import ( + "encoding/json" + "fmt" + "math" + "strings" + "time" + + "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" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const productAgoal = "agoal" + +var agoalLoadLocation = time.LoadLocation + +func agoalResponseError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +func requireAgoalSuccess(data map[string]any, operation string) (map[string]any, error) { + if len(data) == 0 { + return nil, agoalResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明查询成功或结果确实为空") + } + rawSuccess, present := data["success"] + if !present { + return nil, agoalResponseError(operation, "missing_success", "响应缺少 success 布尔终态") + } + success, ok := rawSuccess.(bool) + if !ok { + return nil, agoalResponseError(operation, "malformed_success", "响应 success 字段不是布尔值") + } + if !success { + message, _ := data["message"].(string) + if strings.TrimSpace(message) == "" { + message = "服务明确返回 success=false" + } + return nil, agoalResponseError(operation, "remote_failure", message) + } + for _, key := range []string{"error", "errorCode", "error_code"} { + if value, found := data[key]; found && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" && fmt.Sprint(value) != "0" { + return nil, agoalResponseError(operation, "conflicting_error", fmt.Sprintf("响应 success 与 %s 错误字段冲突", key)) + } + } + return data, nil +} + +func requireAgoalList(data map[string]any, operation string, value any, idKeys ...string) ([]map[string]any, error) { + items, ok := value.([]any) + if !ok { + return nil, agoalResponseError(operation, "malformed_collection", "成功响应的业务集合不是数组") + } + out := make([]map[string]any, 0, len(items)) + for index, raw := range items { + item, ok := raw.(map[string]any) + if !ok { + return nil, agoalResponseError(operation, "malformed_item", fmt.Sprintf("业务集合第 %d 项不是对象", index)) + } + if agoalFirstString(item, idKeys...) == "" { + return nil, agoalResponseError(operation, "missing_item_id", fmt.Sprintf("业务集合第 %d 项缺少稳定身份字段", index)) + } + out = append(out, item) + } + return out, nil +} + +func agoalFirstString(item map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := item[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func agoalNonNegativeInt(value any) (int, bool) { + switch typed := value.(type) { + case int: + return typed, typed >= 0 + case int64: + if typed < 0 || int64(int(typed)) != typed { + return 0, false + } + return int(typed), true + case float64: + if typed < 0 || math.Trunc(typed) != typed || typed > float64(^uint(0)>>1) { + return 0, false + } + return int(typed), true + case json.Number: + parsed, err := typed.Int64() + if err != nil || parsed < 0 || int64(int(parsed)) != parsed { + return 0, false + } + return int(parsed), true + default: + return 0, false + } +} + +func agoalReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func agoalUnavailableSafety(write bool) contract.SafetySpec { + if write { + return contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"} + } + return agoalReadSafety() +} + +func agoalListResult(collection, description string, paged bool, sensitivePaths ...string) *contract.ResultSpec { + properties := map[string]any{ + "count": map[string]any{"type": "integer", "description": "当前结果中的业务记录数量"}, + collection: map[string]any{ + "type": "array", "description": description, + "items": map[string]any{"type": "object", "description": "经稳定身份验证的 Agoal 业务记录", "additionalProperties": true}, + }, + } + required := []string{"count", collection} + if paged { + properties["page"] = map[string]any{"type": "integer", "description": "服务端确认的当前页码"} + properties["pageSize"] = map[string]any{"type": "integer", "description": "服务端确认的当前页容量"} + properties["totalCount"] = map[string]any{"type": "integer", "description": "服务端确认的全部匹配记录数"} + required = append(required, "page", "pageSize", "totalCount") + } + schema, _ := json.Marshal(map[string]any{ + "type": "object", "description": description, "additionalProperties": false, + "properties": properties, "required": required, + }) + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: schema, + SensitivePaths: append([]string(nil), sensitivePaths...), + } +} + +var ReportStatisticsList = shortcut.Shortcut{ + Service: productAgoal, + Command: "+report-statistics-list", + Product: productAgoal, + Description: "查询周月报规则提交统计", + Intent: "当你要盘点周报或月报规则的按时、迟交、未提交汇总,或按规则关键词定位统计项时使用;返回严格验证的统计列表。", + Risk: shortcut.RiskRead, + Safety: agoalReadSafety(), + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: productAgoal, Name: "shortcut_report_statistics_list", + CanonicalPath: "agoal.shortcut_report_statistics_list", CLIPath: "agoal +report-statistics-list", PrimaryCLIPath: "agoal +report-statistics-list", + }, + Description: "查询周月报规则提交统计", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", + Reason: "Reviewed Agoal adapter validates the terminal success marker, collection type, every stable templateId, and the legal empty result before unified output.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询周月报规则提交统计", + UseWhen: []string{"当你要盘点周报或月报规则的按时、迟交、未提交汇总,或按规则关键词定位统计项时使用;返回严格验证的统计列表。"}, + AvoidWhen: []string{"需要查看某一规则下的人员级提交详情时不要使用;该能力尚未完成隐私与分页闭环"}, + Examples: []string{"dws agoal +report-statistics-list --keyword "}, + }, + Result: agoalListResult("statistics", "周月报规则提交统计记录", false, + "statistics.title", "statistics.lastModifier", "statistics.content.name"), + }, + Flags: []shortcut.Flag{{Name: "keyword", Type: shortcut.FlagString, Desc: "规则名称关键词;传入保证不匹配的关键词可得到合法空集合"}}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Changed("keyword") { + params["keyword"] = rt.Str("keyword") + } + raw, err := rt.CallMCPData(productAgoal, "list_report_statistics", params) + if err != nil { + return err + } + if _, err := requireAgoalSuccess(raw, "agoal/list_report_statistics"); err != nil { + return err + } + items, err := requireAgoalList(raw, "agoal/list_report_statistics", raw["content"], "templateId") + if err != nil { + return err + } + return rt.Output(map[string]any{"count": len(items), "statistics": items}) + }, +} + +var ObjectTemplateList = shortcut.Shortcut{ + Service: productAgoal, + Command: "+obj-template-list", + Product: productAgoal, + Description: "分页查询 Agoal 目标模板", + Intent: "当你要盘点或按关键词查找 Agoal 目标模板,并查看服务端确认的页码、页容量和总数时使用;不会修改模板。", + Risk: shortcut.RiskRead, + Safety: agoalReadSafety(), + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: productAgoal, Name: "shortcut_obj_template_list", + CanonicalPath: "agoal.shortcut_obj_template_list", CLIPath: "agoal +obj-template-list", PrimaryCLIPath: "agoal +obj-template-list", + }, + Description: "分页查询 Agoal 目标模板", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", + Reason: "Reviewed Agoal adapter validates success, result array, stable template IDs, and explicit page/pageSize/totalCount facts without inventing cursor pagination.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "分页查询 Agoal 目标模板", + UseWhen: []string{"当你要盘点或按关键词查找 Agoal 目标模板,并查看服务端确认的页码、页容量和总数时使用;不会修改模板。"}, + AvoidWhen: []string{"需要新增或覆盖更新模板时不要使用;写能力尚未具备安全回滚合同"}, + Examples: []string{"dws agoal +obj-template-list --keyword --page 1 --page-size 20"}, + }, + Result: agoalListResult("templates", "Agoal 目标模板查询结果", true, + "templates.title", "templates.creator", "templates.dimensions.title"), + }, + Flags: []shortcut.Flag{ + {Name: "keyword", Type: shortcut.FlagString, Desc: "模板关键词;传入保证不匹配的关键词可得到合法空集合"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,必须大于等于 1"}, + {Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,必须在 1 到 100 之间"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "页码,必须大于等于 1"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"page-size"}, Description: "每页数量,必须在 1 到 100 之间"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("page") < 1 { + return apperrors.NewValidation("--page 必须大于等于 1") + } + if size := rt.Int("page-size"); size < 1 || size > 100 { + return apperrors.NewValidation("--page-size 必须在 1 到 100 之间") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + wantPage := rt.Int("page") + wantSize := rt.Int("page-size") + params := map[string]any{"page": wantPage, "pageSize": wantSize} + if rt.Changed("keyword") { + params["keyword"] = rt.Str("keyword") + } + raw, err := rt.CallMCPData(productAgoal, "list_obj_template", params) + if err != nil { + return err + } + if _, err := requireAgoalSuccess(raw, "agoal/list_obj_template"); err != nil { + return err + } + content, ok := raw["content"].(map[string]any) + if !ok { + return agoalResponseError("agoal/list_obj_template", "malformed_business_result", "成功响应 content 不是分页对象") + } + items, err := requireAgoalList(raw, "agoal/list_obj_template", content["result"], "id", "templateId") + if err != nil { + return err + } + page, pageOK := agoalNonNegativeInt(content["page"]) + pageSize, pageSizeOK := agoalNonNegativeInt(content["pageSize"]) + total, totalOK := agoalNonNegativeInt(content["totalCount"]) + if !pageOK || !pageSizeOK || !totalOK { + return agoalResponseError("agoal/list_obj_template", "malformed_pagination", "分页响应缺少非负整数 page/pageSize/totalCount") + } + if page != wantPage || pageSize != wantSize { + return agoalResponseError("agoal/list_obj_template", "pagination_mismatch", "服务端分页回显与请求不一致") + } + if total < len(items) { + return agoalResponseError("agoal/list_obj_template", "impossible_total", "totalCount 小于当前页记录数") + } + return rt.Output(map[string]any{ + "count": len(items), "templates": items, + "page": page, "pageSize": pageSize, "totalCount": total, + }) + }, +} + +var ContractFields = shortcut.Shortcut{ + Service: productAgoal, Command: "+contract-fields", Product: productAgoal, + Description: "查询经营合约字段配置", + Intent: "当你要查看当前组织可用的经营合约字段,或按字段 ID、编码、标题、分类、类型精确定位配置时使用;不会修改合约。", + Risk: shortcut.RiskRead, + Safety: agoalReadSafety(), + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: productAgoal, Name: "shortcut_contract_fields", + CanonicalPath: "agoal.shortcut_contract_fields", CLIPath: "agoal +contract-fields", PrimaryCLIPath: "agoal +contract-fields", + }, + Description: "查询并严格筛选经营合约字段配置", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", + Reason: "Reviewed adapter requires success, an explicit content array, and a stable id on every field; the optional keyword is applied locally across reviewed scalar field metadata so known and guaranteed-zero cases are provable.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询经营合约字段配置", + UseWhen: []string{"当你要查看当前组织可用的经营合约字段,或按字段 ID、编码、标题、分类、类型精确定位配置时使用;不会修改合约。"}, + AvoidWhen: []string{"查询具体经营合约不要使用;当前合约列表和详情缺少非空安全 fixture"}, + Examples: []string{"dws agoal +contract-fields --keyword "}, + }, + Result: agoalListResult("fields", "经营合约字段配置", false), + }, + Flags: []shortcut.Flag{{Name: "keyword", Type: shortcut.FlagString, Desc: "字段 ID、编码、标题、分类或类型关键词;本地大小写不敏感筛选"}}, + Execute: func(rt *shortcut.RuntimeContext) error { + raw, err := rt.CallMCPData(productAgoal, "list_op_contract_fields", map[string]any{}) + if err != nil { + return err + } + if _, err := requireAgoalSuccess(raw, "agoal/list_op_contract_fields"); err != nil { + return err + } + fields, err := requireAgoalList(raw, "agoal/list_op_contract_fields", raw["content"], "id") + if err != nil { + return err + } + fields = filterAgoalItems(fields, rt.Str("keyword"), "id", "code", "title", "category", "type") + return rt.Output(map[string]any{"count": len(fields), "fields": fields}) + }, +} + +var UserRules = shortcut.Shortcut{ + Service: productAgoal, Command: "+user-rules", Product: productAgoal, + Description: "查询用户 Agoal 规则周期", + Intent: "当你要查看本人或指定用户可见的 Agoal 规则及周期,或按稳定 ruleId 精确确认某项规则时使用。", + Risk: shortcut.RiskRead, + Safety: agoalReadSafety(), + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: productAgoal, Name: "shortcut_user_rules", + CanonicalPath: "agoal.shortcut_user_rules", CLIPath: "agoal +user-rules", PrimaryCLIPath: "agoal +user-rules", + }, + Description: "查询用户规则周期并可按稳定 ruleId 精确筛选", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", + Reason: "Reviewed adapter requires success, a content object, an explicit rules array, and stable rule IDs; the optional rule-id filter is local exact equality and provides a guaranteed-zero proof path.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询用户 Agoal 规则周期", + UseWhen: []string{"当你要查看本人或指定用户可见的 Agoal 规则及周期,或按稳定 ruleId 精确确认某项规则时使用。"}, + AvoidWhen: []string{"需要目标正文时使用 +user-objectives;该目标列表当前缺少非空安全 fixture"}, + Examples: []string{"dws agoal +user-rules --rule-id "}, + }, + Result: agoalListResult("rules", "用户可见的 Agoal 规则与周期", false, + "rules.ruleName", "rules.periodFilter.currentPeriods.nameCn", "rules.periodFilter.historyPeriods.nameCn"), + }, + Flags: []shortcut.Flag{ + {Name: "user-id", Type: shortcut.FlagString, Desc: "可选用户稳定 ID;省略时查询本人"}, + {Name: "rule-id", Type: shortcut.FlagString, Desc: "可选稳定 ruleId;Shortcut 在严格验证完整数组后做精确等值筛选"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Changed("user-id") { + params["dingUserId"] = rt.Str("user-id") + } + raw, err := rt.CallMCPData(productAgoal, "get_user_rules", params) + if err != nil { + return err + } + if _, err := requireAgoalSuccess(raw, "agoal/get_user_rules"); err != nil { + return err + } + content, ok := raw["content"].(map[string]any) + if !ok { + return agoalResponseError("agoal/get_user_rules", "malformed_business_result", "成功响应 content 不是规则对象") + } + rules, err := requireAgoalList(raw, "agoal/get_user_rules", content["rules"], "id") + if err != nil { + return err + } + wantRule := strings.TrimSpace(rt.Str("rule-id")) + if wantRule != "" { + filtered := make([]map[string]any, 0, 1) + for _, rule := range rules { + if agoalFirstString(rule, "id") == wantRule { + filtered = append(filtered, rule) + } + } + rules = filtered + } + return rt.Output(map[string]any{"count": len(rules), "rules": rules}) + }, +} + +var ReportSubmitDetail = shortcut.Shortcut{ + Service: productAgoal, Command: "+report-submit-detail", Product: productAgoal, + Description: "分页查询周月报人员提交详情", + Intent: "当你要按周月报规则、提交状态、日期或人员关键词查看具体人员提交详情时使用;返回严格验证的人员身份和页码事实。", + Risk: shortcut.RiskRead, + Safety: agoalReadSafety(), + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: productAgoal, Name: "shortcut_report_submit_detail", + CanonicalPath: "agoal.shortcut_report_submit_detail", CLIPath: "agoal +report-submit-detail", PrimaryCLIPath: "agoal +report-submit-detail", + }, + Description: "分页查询周月报人员提交详情", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", + Reason: "Reviewed adapter requires success, a paged content object, an explicit result array, stable nested user identity on every row, exact page/pageSize echo, and a valid totalCount. Because the lower keyword is ignored, keyword queries use one bounded full traversal with total consistency, duplicate, stall, and page-limit guards before local filtering; only reviewed personnel fields are projected and declared sensitive.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "分页查询周月报人员提交详情", + UseWhen: []string{"当你要按周月报规则、提交状态、日期或人员关键词查看具体人员提交详情时使用;返回严格验证的人员身份和页码事实。"}, + AvoidWhen: []string{"只需规则级汇总时使用 +report-statistics-list;不要把人员级原始结果写入审计文档"}, + Examples: []string{"dws agoal +report-submit-detail --template-id --submit-state NOT_SUBMITTED --page 1 --page-size 20"}, + }, + Result: agoalListResult("submissions", "周月报人员提交详情", true, + "submissions.reportId", "submissions.user.dingUserId", "submissions.user.id", "submissions.user.name", "submissions.user.workNo"), + }, + Flags: []shortcut.Flag{ + {Name: "template-id", Type: shortcut.FlagString, Desc: "周月报规则模板稳定 ID", Required: true}, + {Name: "submit-state", Type: shortcut.FlagString, Desc: "提交状态", Required: true, Enum: []string{"ON_TIME", "LATE", "NOT_SUBMITTED"}}, + {Name: "query-date", Type: shortcut.FlagString, Desc: "可选 ISO-8601 日期或时间"}, + {Name: "keyword", Type: shortcut.FlagString, Desc: "可选人员名称关键词;可用于保证零命中"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,必须大于等于 1"}, + {Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,必须在 1 到 100 之间"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "页码,必须大于等于 1"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"page-size"}, Description: "每页数量,必须在 1 到 100 之间"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("page") < 1 { + return apperrors.NewValidation("--page 必须大于等于 1") + } + if size := rt.Int("page-size"); size < 1 || size > 100 { + return apperrors.NewValidation("--page-size 必须在 1 到 100 之间") + } + if rt.Changed("query-date") { + if _, err := normalizeAgoalQueryDate(rt.Str("query-date")); err != nil { + return err + } + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + wantPage, wantSize := rt.Int("page"), rt.Int("page-size") + baseParams := map[string]any{ + "templateId": rt.Str("template-id"), "submitState": strings.ToUpper(rt.Str("submit-state")), + } + if rt.Changed("query-date") { + // Validate has already accepted this exact immutable Cobra value. + queryDate, _ := normalizeAgoalQueryDate(rt.Str("query-date")) + baseParams["queryDate"] = queryDate + } + keyword := strings.TrimSpace(rt.Str("keyword")) + if keyword == "" { + items, total, err := readAgoalSubmissionPage(rt, baseParams, wantPage, wantSize) + if err != nil { + return err + } + return rt.Output(map[string]any{"count": len(items), "submissions": items, "page": wantPage, "pageSize": wantSize, "totalCount": total}) + } + all, err := readAllAgoalSubmissions(rt, baseParams) + if err != nil { + return err + } + filtered := filterAgoalSubmissions(all, keyword) + start := len(filtered) + pageOffset := wantPage - 1 + if pageOffset <= len(filtered)/wantSize { + start = pageOffset * wantSize + } + end := start + wantSize + if end > len(filtered) { + end = len(filtered) + } + items := filtered[start:end] + return rt.Output(map[string]any{"count": len(items), "submissions": items, "page": wantPage, "pageSize": wantSize, "totalCount": len(filtered)}) + }, +} + +const ( + agoalSubmissionReadPageSize = 100 + agoalSubmissionMaxPages = 20 +) + +func readAgoalSubmissionPage(rt *shortcut.RuntimeContext, baseParams map[string]any, page, pageSize int) ([]map[string]any, int, error) { + params := make(map[string]any, len(baseParams)+2) + for key, value := range baseParams { + params[key] = value + } + params["page"] = page + params["pageSize"] = pageSize + raw, err := rt.CallMCPData(productAgoal, "get_submit_detail", params) + if err != nil { + return nil, 0, err + } + if _, err := requireAgoalSuccess(raw, "agoal/get_submit_detail"); err != nil { + return nil, 0, err + } + content, ok := raw["content"].(map[string]any) + if !ok { + return nil, 0, agoalResponseError("agoal/get_submit_detail", "malformed_business_result", "成功响应 content 不是分页对象") + } + items, err := requireAgoalSubmissionList(content["result"]) + if err != nil { + return nil, 0, err + } + gotPage, pageOK := agoalNonNegativeInt(content["page"]) + gotSize, sizeOK := agoalNonNegativeInt(content["pageSize"]) + total, totalOK := agoalNonNegativeInt(content["totalCount"]) + if !pageOK || !sizeOK || !totalOK { + return nil, 0, agoalResponseError("agoal/get_submit_detail", "malformed_pagination", "分页响应缺少非负整数 page/pageSize/totalCount") + } + if gotPage != page || gotSize != pageSize { + return nil, 0, agoalResponseError("agoal/get_submit_detail", "pagination_mismatch", "服务端分页回显与请求不一致") + } + if total < len(items) { + return nil, 0, agoalResponseError("agoal/get_submit_detail", "impossible_total", "totalCount 小于当前页记录数") + } + return items, total, nil +} + +func readAllAgoalSubmissions(rt *shortcut.RuntimeContext, baseParams map[string]any) ([]map[string]any, error) { + all := make([]map[string]any, 0) + seen := map[string]bool{} + wantTotal := -1 + for page := 1; page <= agoalSubmissionMaxPages; page++ { + items, total, err := readAgoalSubmissionPage(rt, baseParams, page, agoalSubmissionReadPageSize) + if err != nil { + return nil, err + } + if wantTotal < 0 { + wantTotal = total + } else if total != wantTotal { + return nil, agoalResponseError("agoal/get_submit_detail", "pagination_total_changed", "分页遍历期间 totalCount 发生变化") + } + for _, item := range items { + identity := agoalSubmissionIdentity(item) + if seen[identity] { + return nil, agoalResponseError("agoal/get_submit_detail", "pagination_duplicate", "分页遍历出现重复人员提交身份") + } + seen[identity] = true + all = append(all, item) + } + if len(all) == wantTotal { + return all, nil + } + if len(items) == 0 { + return nil, agoalResponseError("agoal/get_submit_detail", "pagination_stall", "尚有记录时服务返回空页,分页无法推进") + } + } + return nil, agoalResponseError("agoal/get_submit_detail", "readback_page_limit", "人员提交详情超过有界分页上限") +} + +func agoalSubmissionIdentity(item map[string]any) string { + if reportID := agoalFirstString(item, "reportId"); reportID != "" { + return "report:" + reportID + } + user, _ := item["user"].(map[string]any) + return "user:" + agoalFirstString(user, "dingUserId", "id") +} + +func filterAgoalSubmissions(items []map[string]any, keyword string) []map[string]any { + keyword = strings.ToLower(strings.TrimSpace(keyword)) + if keyword == "" { + return items + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + user, _ := item["user"].(map[string]any) + if strings.Contains(strings.ToLower(agoalFirstString(user, "name")), keyword) { + out = append(out, item) + } + } + return out +} + +func filterAgoalItems(items []map[string]any, keyword string, keys ...string) []map[string]any { + keyword = strings.ToLower(strings.TrimSpace(keyword)) + if keyword == "" { + return items + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + for _, key := range keys { + value, ok := item[key].(string) + if ok && strings.Contains(strings.ToLower(value), keyword) { + out = append(out, item) + break + } + } + } + return out +} + +func requireAgoalSubmissionList(value any) ([]map[string]any, error) { + rawItems, ok := value.([]any) + if !ok { + return nil, agoalResponseError("agoal/get_submit_detail", "malformed_collection", "成功响应 result 不是数组") + } + out := make([]map[string]any, 0, len(rawItems)) + for index, raw := range rawItems { + item, ok := raw.(map[string]any) + if !ok { + return nil, agoalResponseError("agoal/get_submit_detail", "malformed_item", fmt.Sprintf("人员提交详情第 %d 项不是对象", index)) + } + user, ok := item["user"].(map[string]any) + if !ok || agoalFirstString(user, "dingUserId", "id") == "" { + return nil, agoalResponseError("agoal/get_submit_detail", "missing_item_id", fmt.Sprintf("人员提交详情第 %d 项缺少稳定用户身份", index)) + } + projectedUser := map[string]any{} + for _, key := range []string{"dingUserId", "id", "name", "workNo"} { + if value, found := user[key]; found { + projectedUser[key] = value + } + } + projected := map[string]any{"user": projectedUser} + for _, key := range []string{"reportId", "publishTime"} { + if value, found := item[key]; found { + projected[key] = value + } + } + out = append(out, projected) + } + return out, nil +} + +func normalizeAgoalQueryDate(value string) (string, error) { + value = strings.TrimSpace(value) + if parsed, err := time.Parse("2006-01-02", value); err == nil { + return parsed.Format("2006-01-02"), nil + } + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + location, loadErr := agoalLoadLocation("Asia/Shanghai") + if loadErr != nil { + location = time.FixedZone("CST", 8*60*60) + } + return parsed.In(location).Format("2006-01-02"), nil + } + return "", apperrors.NewValidation("--query-date 必须是 YYYY-MM-DD 或 RFC3339 时间") +} + +type unavailableAgoalSpec struct { + command string + description string + intent string + tool string + flags []shortcut.Flag + write bool + reason string +} + +func agoalStringFlag(name, description string, required bool) shortcut.Flag { + return shortcut.Flag{Name: name, Type: shortcut.FlagString, Desc: description, Required: required} +} + +func unavailableAgoalShortcut(spec unavailableAgoalSpec) shortcut.Shortcut { + flagNames := make([]string, 0, len(spec.flags)) + for _, flag := range spec.flags { + flagNames = append(flagNames, flag.Name) + } + unavailable := func(*shortcut.RuntimeContext) error { + return apperrors.NewValidation("该 Agoal Shortcut 当前 unavailable:" + spec.reason) + } + return shortcut.Shortcut{ + Service: productAgoal, Command: spec.command, Product: productAgoal, + Description: spec.description, Intent: spec.intent, + Risk: func() shortcut.Risk { + if spec.write { + return shortcut.RiskHighWrite + } + return shortcut.RiskRead + }(), + Safety: agoalUnavailableSafety(spec.write), + Flags: spec.flags, + Hidden: true, + Availability: shortcut.AvailabilityUnavailable, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, Flags: flagNames, + Description: "该命令保持 unavailable,直到文档化的安全验证前置条件满足", + }}, + SemanticDelta: "Atomic route " + spec.tool + " remains unavailable: " + spec.reason, + Validate: unavailable, + Execute: unavailable, + } +} + +var unavailableAgoalSpecs = []unavailableAgoalSpec{ + {command: "+strategy-list", description: "按范围查询战略解码", intent: "按部门或个人范围查询战略解码列表。", tool: "list_strategy_decodings", flags: []shortcut.Flag{ + {Name: "scope-type", Type: shortcut.FlagString, Desc: "范围类型", Required: true, Enum: []string{"DEPT", "PERSONAL"}}, + agoalStringFlag("scope-id", "部门或人员稳定 ID", true), + }, reason: "exact+raw 在当前可访问 PERSONAL 与 DEPT 范围均仅返回显式空数组,缺少已知非空 fixture"}, + {command: "+strategy-get", description: "查询战略解码详情", intent: "按 profileId 查询单个战略解码。", tool: "get_strategy_decoding_detail", flags: []shortcut.Flag{ + agoalStringFlag("profile-id", "战略解码稳定 ID", true), + }, reason: "raw 对不存在 profileId 返回 null 且无 success 终态,列表又无非空 fixture,无法绑定稳定身份"}, + {command: "+strategy-update", description: "覆盖更新战略解码", intent: "基于旧数据覆盖更新战略解码。", tool: "update_strategy_decoding", write: true, flags: []shortcut.Flag{ + agoalStringFlag("profile-id", "战略解码稳定 ID", true), agoalStringFlag("content", "完整实体 JSON 数组", true), + }, reason: "raw 仅完成 dry-run payload;没有可读旧值 fixture、全字段精确读回与恢复闭环"}, + {command: "+contract-list", description: "按范围查询经营合约", intent: "按部门或个人范围查询经营合约列表。", tool: "list_op_contracts", flags: []shortcut.Flag{ + {Name: "scope-type", Type: shortcut.FlagString, Desc: "范围类型", Required: true, Enum: []string{"DEPT", "PERSONAL"}}, + agoalStringFlag("scope-id", "部门或人员稳定 ID", true), + }, reason: "exact+raw 在当前可访问 PERSONAL 与 DEPT 范围均仅返回显式空数组,缺少已知非空 fixture"}, + {command: "+contract-get", description: "查询经营合约详情", intent: "按 contractId 查询单个经营合约。", tool: "get_op_contract_detail", flags: []shortcut.Flag{ + agoalStringFlag("contract-id", "经营合约稳定 ID", true), + }, reason: "raw 对不存在 contractId 返回 null 且无 success 终态,列表又无非空 fixture,无法绑定稳定身份"}, + {command: "+contract-update", description: "覆盖更新经营合约", intent: "基于旧数据覆盖经营合约全部维度。", tool: "update_op_contract", write: true, flags: []shortcut.Flag{ + agoalStringFlag("contract-id", "经营合约稳定 ID", true), agoalStringFlag("dimensions", "完整维度 JSON 数组", true), + }, reason: "raw 仅完成 dry-run payload;没有可读旧值 fixture、全字段精确读回与恢复闭环"}, + {command: "+scorecard-get", description: "查询计分卡详情", intent: "按部门与周期查询计分卡。", tool: "get_score_card_detail", flags: []shortcut.Flag{ + agoalStringFlag("selected-time", "ISO-8601 周期起始时间", true), agoalStringFlag("dept-id", "部门稳定 ID", true), + }, reason: "exact+raw 对可访问部门和多个受控周期均返回 null 且无 success 终态,缺少非空计分卡 fixture"}, + {command: "+scorecard-entity-get", description: "查询计分卡实体详情", intent: "按计分卡与实体 ID 查询详情。", tool: "get_score_card_entity_detail", flags: []shortcut.Flag{ + agoalStringFlag("sc-id", "计分卡稳定 ID", true), agoalStringFlag("entity-id", "实体稳定 ID", true), + }, reason: "raw 对不存在计分卡/实体双 ID 返回 null 且无 success 终态,无法取得可安全读取的双 ID fixture"}, + {command: "+scorecard-update", description: "覆盖更新计分卡", intent: "基于旧数据覆盖更新计分卡内容。", tool: "update_score_card", write: true, flags: []shortcut.Flag{ + agoalStringFlag("dept-id", "部门稳定 ID", true), agoalStringFlag("selected-time", "ISO-8601 周期起始时间", true), agoalStringFlag("id", "计分卡稳定 ID", true), + {Name: "tracking-period-type", Type: shortcut.FlagString, Desc: "跟踪周期", Required: true, Enum: []string{"MONTHLY", "QUARTERLY"}}, + agoalStringFlag("content", "完整计分卡 JSON 数组", true), + }, reason: "raw 仅完成 dry-run payload;没有可读计分卡 fixture、全字段精确读回与恢复闭环"}, + {command: "+user-objectives", description: "查询用户目标", intent: "按用户、规则与周期查询目标。", tool: "list_user_objectives", flags: []shortcut.Flag{ + agoalStringFlag("user-id", "人员稳定 ID", true), agoalStringFlag("rule-id", "规则稳定 ID", true), agoalStringFlag("period-ids", "周期 ID CSV", true), + }, reason: "exact+raw 使用当前用户及全部可见规则/周期均只返回显式空数组,缺少已知非空目标 fixture"}, + {command: "+obj-template-upsert", description: "新增或覆盖更新目标模板", intent: "新增目标模板或基于旧数据覆盖更新模板。", tool: "create_or_update_obj_template", write: true, flags: []shortcut.Flag{ + agoalStringFlag("template-id", "更新时的模板稳定 ID", false), agoalStringFlag("title", "新增时的模板标题", false), agoalStringFlag("dimensions", "完整维度 JSON", true), + {Name: "objective-weight", Type: shortcut.FlagBool, Desc: "是否启用目标权重"}, + {Name: "dimension-weight", Type: shortcut.FlagBool, Desc: "是否启用维度权重"}, + {Name: "compute-by-weight", Type: shortcut.FlagBool, Desc: "维度是否参与计算"}, + }, reason: "raw 仅完成 dry-run payload;原子面无模板删除或版本恢复,无法验证 create/update 终态与零残留"}, +} + +func init() { + contract.RegisterProductDecl(contract.ProductDecl{ + ID: productAgoal, + Selection: contract.ProductSelectionDecl{ + AgentSummary: "查询钉钉 Agoal 的周月报统计、人员提交详情、合约字段、用户规则与目标模板;其余能力按安全证据逐步开放", + UseWhen: []string{"用户明确处理钉钉 Agoal、战略解码、经营合约、计分卡、周月报统计或目标模板时使用"}, + AvoidWhen: []string{"用户处理 Lark OKR 的 Objective、Key Result、Progress 或 Alignment 时不要使用;两者数据模型和平台不同"}, + }, + }) + items := []shortcut.Shortcut{ReportStatisticsList, ObjectTemplateList, ContractFields, UserRules, ReportSubmitDetail} + for _, spec := range unavailableAgoalSpecs { + items = append(items, unavailableAgoalShortcut(spec)) + } + for index := range items { + if items[index].Availability != shortcut.AvailabilityUnavailable { + items[index].OutputRollout = output.RolloutUnifiedActive + } + } + shortcut.Register(items...) +} diff --git a/internal/shortcut/agoal/agoal_changed_line_cross_platform_coverage_test.go b/internal/shortcut/agoal/agoal_changed_line_cross_platform_coverage_test.go new file mode 100644 index 00000000..cc831ecc --- /dev/null +++ b/internal/shortcut/agoal/agoal_changed_line_cross_platform_coverage_test.go @@ -0,0 +1,201 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package agoal + +import ( + "encoding/json" + "errors" + "math" + "strconv" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageAgoalScalarAndProjectionEdges(t *testing.T) { + for _, tc := range []struct { + name string + value any + want int + ok bool + }{ + {name: "int", value: 3, want: 3, ok: true}, + {name: "negative int", value: -1, want: -1}, + {name: "int64", value: int64(4), want: 4, ok: true}, + {name: "negative int64", value: int64(-1)}, + {name: "float", value: float64(5), want: 5, ok: true}, + {name: "negative float", value: float64(-1)}, + {name: "fraction", value: 1.5}, + {name: "huge float", value: math.MaxFloat64}, + {name: "json number", value: json.Number("6"), want: 6, ok: true}, + {name: "bad json number", value: json.Number("1.5")}, + {name: "negative json number", value: json.Number("-1")}, + {name: "wrong type", value: "7"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, ok := agoalNonNegativeInt(tc.value) + if got != tc.want || ok != tc.ok { + t.Fatalf("agoalNonNegativeInt(%#v) = (%d,%v), want (%d,%v)", tc.value, got, ok, tc.want, tc.ok) + } + }) + } + + items := []map[string]any{{"id": "one", "name": "Known"}} + if got := filterAgoalItems(items, "", "name"); len(got) != 1 { + t.Fatalf("empty item filter = %#v", got) + } + if got := filterAgoalSubmissions(items, ""); len(got) != 1 { + t.Fatalf("empty submission filter = %#v", got) + } + if got := agoalSubmissionIdentity(map[string]any{"user": map[string]any{"id": "user"}}); got != "user:user" { + t.Fatalf("fallback identity = %q", got) + } + + for _, bad := range []any{ + nil, + []any{"bad"}, + []any{map[string]any{"user": "bad"}}, + []any{map[string]any{"user": map[string]any{"name": "missing identity"}}}, + } { + if _, err := requireAgoalSubmissionList(bad); err == nil { + t.Fatalf("malformed submission list accepted: %#v", bad) + } + } +} + +func TestCrossPlatformCoverageAgoalDateNormalizationEdges(t *testing.T) { + if got, err := normalizeAgoalQueryDate(" 2026-08-24 "); err != nil || got != "2026-08-24" { + t.Fatalf("date normalization = %q, %v", got, err) + } + if got, err := normalizeAgoalQueryDate("2026-08-24T01:00:00Z"); err != nil || got != "2026-08-24" { + t.Fatalf("RFC3339 normalization = %q, %v", got, err) + } + testseam.Swap(t, &agoalLoadLocation, func(string) (*time.Location, error) { + return nil, errors.New("fixture location failure") + }) + if got, err := normalizeAgoalQueryDate("2026-08-24T18:00:00Z"); err != nil || got != "2026-08-25" { + t.Fatalf("fallback timezone normalization = %q, %v", got, err) + } +} + +func TestCrossPlatformCoverageAgoalLeafFailureWiring(t *testing.T) { + tests := []struct { + name string + declaration shortcut.Shortcut + args []string + tool string + response map[string]any + }{ + {name: "report transport", declaration: ReportStatisticsList, tool: "list_report_statistics"}, + {name: "report failure", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"success": false}}, + {name: "report conflicting error", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"success": true, "errorCode": "DENIED", "content": []any{}}}, + {name: "report missing success", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"content": []any{}}}, + {name: "report malformed items", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"success": true, "content": []any{"bad"}}}, + {name: "template transport", declaration: ObjectTemplateList, tool: "list_obj_template"}, + {name: "template failure", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": false}}, + {name: "template conflicting error", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "errorCode": "DENIED", "content": map[string]any{}}}, + {name: "template missing success", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"content": map[string]any{}}}, + {name: "template content", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": []any{}}}, + {name: "template items", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": map[string]any{"result": "bad", "page": 1, "pageSize": 20, "totalCount": 0}}}, + {name: "template pagination", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": "1", "pageSize": 20, "totalCount": 0}}}, + {name: "template page mismatch", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": 2, "pageSize": 20, "totalCount": 0}}}, + {name: "fields transport", declaration: ContractFields, tool: "list_op_contract_fields"}, + {name: "fields failure", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"success": false}}, + {name: "fields conflicting error", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"success": true, "errorCode": "DENIED", "content": []any{}}}, + {name: "fields missing success", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"content": []any{}}}, + {name: "fields items", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"success": true, "content": []any{"bad"}}}, + {name: "rules transport", declaration: UserRules, tool: "get_user_rules"}, + {name: "rules failure", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": false}}, + {name: "rules conflicting error", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": true, "errorCode": "DENIED", "content": map[string]any{}}}, + {name: "rules missing success", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"content": map[string]any{}}}, + {name: "rules content", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": true, "content": []any{}}}, + {name: "rules items", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": true, "content": map[string]any{"rules": []any{"bad"}}}}, + {name: "submission transport", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail"}, + {name: "submission failure", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": false}}, + {name: "submission conflicting error", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "errorCode": "DENIED", "content": map[string]any{}}}, + {name: "submission missing success", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"content": map[string]any{}}}, + {name: "submission content", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": []any{}}}, + {name: "submission items", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": map[string]any{"result": "bad", "page": 1, "pageSize": 20, "totalCount": 0}}}, + {name: "submission pagination", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": "1", "pageSize": 20, "totalCount": 0}}}, + {name: "submission size mismatch", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": 1, "pageSize": 21, "totalCount": 0}}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &agoalCoverageCaller{} + if tc.response != nil { + caller.responses = map[string][]map[string]any{tc.tool: {tc.response}} + } + if err := runAgoalCoverage(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatal("failure fixture was accepted") + } + }) + } + + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_user_rules": {{"success": true, "content": map[string]any{"rules": []any{map[string]any{"id": "rule"}}}}}, + }} + if err := runAgoalCoverage(t, UserRules, caller); err != nil { + t.Fatal(err) + } +} + +func TestCrossPlatformCoverageAgoalBoundedPaginationAndDateExecution(t *testing.T) { + responses := make([]map[string]any, 0, agoalSubmissionMaxPages) + for page := 1; page <= agoalSubmissionMaxPages; page++ { + responses = append(responses, map[string]any{"success": true, "content": map[string]any{ + "page": page, "pageSize": agoalSubmissionReadPageSize, "totalCount": agoalSubmissionMaxPages + 1, + "result": []any{map[string]any{"reportId": "report-" + json.Number(string(rune('A'+page-1))).String(), "user": map[string]any{"id": "user"}}}, + }}) + } + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{"get_submit_detail": responses}} + if err := runAgoalCoverage(t, ReportSubmitDetail, caller, + "--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "known"); err == nil { + t.Fatal("bounded pagination limit was accepted as complete") + } + + scanFailure := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"content": map[string]any{}}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, scanFailure, + "--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "known"); err == nil { + t.Fatal("scan page validation failure was accepted") + } + + dateCaller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"success": true, "content": map[string]any{ + "page": 1, "pageSize": 20, "totalCount": 0, "result": []any{}, + }}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, dateCaller, + "--template-id", "template", "--submit-state", "ON_TIME", "--query-date", "2026-08-24T01:00:00Z"); err != nil { + t.Fatal(err) + } + if got := dateCaller.calls[0].params["queryDate"]; got != "2026-08-24" { + t.Fatalf("queryDate = %#v", got) + } + + laterPage := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"success": true, "content": map[string]any{ + "page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 1, + "result": []any{map[string]any{"reportId": "report", "user": map[string]any{"id": "user", "name": "Different"}}}, + }}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, laterPage, + "--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "missing", "--page", "2"); err != nil { + t.Fatal(err) + } + maximumPage := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"success": true, "content": map[string]any{ + "page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 1, + "result": []any{map[string]any{"reportId": "report", "user": map[string]any{"id": "user", "name": "Different"}}}, + }}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, maximumPage, + "--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "missing", + "--page", strconv.Itoa(int(^uint(0)>>1))); err != nil { + t.Fatalf("maximum page must project an empty local page without overflowing: %v", err) + } +} diff --git a/internal/shortcut/agoal/agoal_hardening_test.go b/internal/shortcut/agoal/agoal_hardening_test.go new file mode 100644 index 00000000..9c3aba7c --- /dev/null +++ b/internal/shortcut/agoal/agoal_hardening_test.go @@ -0,0 +1,434 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package agoal + +import ( + "context" + "encoding/json" + "errors" + "io" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type agoalCoverageCall struct { + tool string + params map[string]any +} + +type agoalCoverageCaller struct { + responses map[string][]map[string]any + calls []agoalCoverageCall +} + +func (c *agoalCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) { + c.calls = append(c.calls, agoalCoverageCall{tool: tool, params: params}) + queue := c.responses[tool] + if len(queue) == 0 { + return nil, errors.New("unexpected agoal coverage call: " + tool) + } + payload := queue[0] + c.responses[tool] = queue[1:] + encoded, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(encoded)}}}, nil +} + +func (*agoalCoverageCaller) Format() string { return "json" } +func (*agoalCoverageCaller) DryRun() bool { return false } +func (*agoalCoverageCaller) Fields() string { return "" } +func (*agoalCoverageCaller) JQ() string { return "" } + +func runAgoalCoverage(t *testing.T, declaration shortcut.Shortcut, caller *agoalCoverageCaller, args ...string) error { + t.Helper() + helpers.InitDeps(caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + service := &cobra.Command{Use: productAgoal} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetIn(strings.NewReader("")) + root.SetArgs(append([]string{productAgoal, declaration.Command}, args...)) + return root.Execute() +} + +func agoalRegistered(command string) (shortcut.Shortcut, bool) { + for _, item := range shortcut.All() { + if item.Service == productAgoal && item.Command == command { + return item, true + } + } + return shortcut.Shortcut{}, false +} + +func TestCrossPlatformCoverageAgoalStrictResponseValidation(t *testing.T) { + for _, tc := range []struct { + name string + data map[string]any + }{ + {name: "empty", data: nil}, + {name: "missing success", data: map[string]any{"content": []any{}}}, + {name: "wrong success", data: map[string]any{"success": "true", "content": []any{}}}, + {name: "explicit failure", data: map[string]any{"success": false, "content": []any{}}}, + {name: "conflicting error", data: map[string]any{"success": true, "errorCode": "DENIED", "content": []any{}}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := requireAgoalSuccess(tc.data, "agoal/test"); err == nil { + t.Fatal("malformed response was accepted") + } + }) + } + + for _, tc := range []struct { + name string + value any + }{ + {name: "missing collection", value: nil}, + {name: "wrong collection", value: map[string]any{}}, + {name: "bad element", value: []any{"bad"}}, + {name: "missing id", value: []any{map[string]any{"title": "template"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := requireAgoalList(map[string]any{"success": true}, "agoal/test", tc.value, "id"); err == nil { + t.Fatal("malformed collection was accepted") + } + }) + } +} + +func TestCrossPlatformCoverageAgoalPublicReadsUseExactToolsAndShapes(t *testing.T) { + t.Run("report nonempty", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "list_report_statistics": {{"success": true, "content": []any{map[string]any{"templateId": "template"}}}}, + }} + if err := runAgoalCoverage(t, ReportStatisticsList, caller, "--keyword", "weekly"); err != nil { + t.Fatal(err) + } + want := []agoalCoverageCall{{tool: "list_report_statistics", params: map[string]any{"keyword": "weekly"}}} + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) + + t.Run("report legal zero", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "list_report_statistics": {{"success": true, "content": []any{}}}, + }} + if err := runAgoalCoverage(t, ReportStatisticsList, caller, "--keyword", "guaranteed-zero"); err != nil { + t.Fatal(err) + } + }) + + t.Run("template nonempty page", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "list_obj_template": {{"success": true, "content": map[string]any{ + "page": 2, "pageSize": 5, "totalCount": 6, + "result": []any{map[string]any{"id": "template"}}, + }}}, + }} + if err := runAgoalCoverage(t, ObjectTemplateList, caller, "--keyword", "target", "--page", "2", "--page-size", "5"); err != nil { + t.Fatal(err) + } + want := []agoalCoverageCall{{tool: "list_obj_template", params: map[string]any{"keyword": "target", "page": 2, "pageSize": 5}}} + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) + + t.Run("template rejects false pagination", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "list_obj_template": {{"success": true, "content": map[string]any{ + "page": 1, "pageSize": 20, "totalCount": 0, + "result": []any{map[string]any{"id": "impossible"}}, + }}}, + }} + if err := runAgoalCoverage(t, ObjectTemplateList, caller); err == nil { + t.Fatal("impossible pagination total was accepted") + } + }) +} + +func TestCrossPlatformCoverageAgoalResidualReads(t *testing.T) { + t.Run("contract fields known and guaranteed zero", func(t *testing.T) { + items := []map[string]any{{"id": "field-1", "code": "revenue", "title": "Revenue"}} + if got := filterAgoalItems(items, "revenue", "id", "code", "title"); len(got) != 1 { + t.Fatalf("known field projection = %#v", got) + } + if got := filterAgoalItems(items, "guaranteed-zero", "id", "code", "title"); len(got) != 0 { + t.Fatalf("zero field projection = %#v", got) + } + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "list_op_contract_fields": {{"success": true, "content": []any{items[0]}}}, + }} + if err := runAgoalCoverage(t, ContractFields, caller, "--keyword", "revenue"); err != nil { + t.Fatal(err) + } + want := []agoalCoverageCall{{tool: "list_op_contract_fields", params: map[string]any{}}} + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) + + t.Run("user rules exact filter", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_user_rules": {{"success": true, "content": map[string]any{ + "rules": []any{map[string]any{"id": "rule-1", "ruleName": "Rule"}}, + }}}, + }} + if err := runAgoalCoverage(t, UserRules, caller, "--user-id", "user", "--rule-id", "rule-1"); err != nil { + t.Fatal(err) + } + want := []agoalCoverageCall{{tool: "get_user_rules", params: map[string]any{"dingUserId": "user"}}} + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + zero := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_user_rules": {{"success": true, "content": map[string]any{ + "rules": []any{map[string]any{"id": "rule-1"}}, + }}}, + }} + if err := runAgoalCoverage(t, UserRules, zero, "--rule-id", "guaranteed-zero"); err != nil { + t.Fatal(err) + } + }) + + t.Run("report submission page and privacy identity", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"success": true, "content": map[string]any{ + "page": 1, "pageSize": 20, "totalCount": 1, + "result": []any{map[string]any{"reportId": nil, "user": map[string]any{"dingUserId": "user"}}}, + }}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, + caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--page", "1", "--page-size", "20"); err != nil { + t.Fatal(err) + } + want := []agoalCoverageCall{{tool: "get_submit_detail", params: map[string]any{ + "templateId": "template", "submitState": "NOT_SUBMITTED", "page": 1, "pageSize": 20, + }}} + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) + + t.Run("report keyword scans then filters locally", func(t *testing.T) { + items := []map[string]any{ + {"reportId": "report-1", "user": map[string]any{"dingUserId": "user-1", "name": "Known Person"}}, + {"reportId": "report-2", "user": map[string]any{"dingUserId": "user-2", "name": "Another Person"}}, + } + if got := filterAgoalSubmissions(items, "known"); len(got) != 1 { + t.Fatalf("known submission projection = %#v", got) + } + if got := filterAgoalSubmissions(items, "guaranteed-zero"); len(got) != 0 { + t.Fatalf("zero submission projection = %#v", got) + } + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"success": true, "content": map[string]any{ + "page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, + "result": []any{items[0], items[1]}, + }}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, + caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "guaranteed-zero", "--page", "1", "--page-size", "20"); err != nil { + t.Fatal(err) + } + want := []agoalCoverageCall{{tool: "get_submit_detail", params: map[string]any{ + "templateId": "template", "submitState": "NOT_SUBMITTED", "page": 1, "pageSize": agoalSubmissionReadPageSize, + }}} + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) + + t.Run("report keyword scan rejects duplicate pages", func(t *testing.T) { + item := map[string]any{"reportId": "report-1", "user": map[string]any{"id": "user-1", "name": "Known"}} + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": { + {"success": true, "content": map[string]any{"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{item}}}, + {"success": true, "content": map[string]any{"page": 2, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{item}}}, + }, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, + caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "known"); err == nil { + t.Fatal("duplicate submission pagination was accepted") + } + }) + + t.Run("report keyword scan rejects changing total", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": { + {"success": true, "content": map[string]any{"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{map[string]any{"reportId": "report-1", "user": map[string]any{"id": "user-1", "name": "Known"}}}}}, + {"success": true, "content": map[string]any{"page": 2, "pageSize": agoalSubmissionReadPageSize, "totalCount": 3, "result": []any{map[string]any{"reportId": "report-2", "user": map[string]any{"id": "user-2", "name": "Known"}}}}}, + }, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, + caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "known"); err == nil { + t.Fatal("changing pagination total was accepted") + } + }) + + t.Run("report keyword scan rejects stalled page", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": { + {"success": true, "content": map[string]any{"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{map[string]any{"reportId": "report-1", "user": map[string]any{"id": "user-1", "name": "Known"}}}}}, + {"success": true, "content": map[string]any{"page": 2, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{}}}, + }, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, + caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "known"); err == nil { + t.Fatal("stalled pagination was accepted") + } + }) + + t.Run("report projection drops unreviewed personnel fields", func(t *testing.T) { + items, err := requireAgoalSubmissionList([]any{map[string]any{ + "reportId": "report", "unreviewed": "drop", + "user": map[string]any{"id": "user", "name": "Known", "email": "drop"}, + }}) + if err != nil { + t.Fatal(err) + } + if _, found := items[0]["unreviewed"]; found { + t.Fatal("unreviewed submission field was projected") + } + user := items[0]["user"].(map[string]any) + if _, found := user["email"]; found { + t.Fatal("unreviewed personnel field was projected") + } + }) + + for _, tc := range []struct { + name string + content map[string]any + }{ + {name: "missing stable user", content: map[string]any{"page": 1, "pageSize": 20, "totalCount": 1, "result": []any{map[string]any{"user": map[string]any{"name": "x"}}}}}, + {name: "page mismatch", content: map[string]any{"page": 2, "pageSize": 20, "totalCount": 0, "result": []any{}}}, + {name: "impossible total", content: map[string]any{"page": 1, "pageSize": 20, "totalCount": 0, "result": []any{map[string]any{"user": map[string]any{"id": "user"}}}}}, + } { + t.Run("report rejects "+tc.name, func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"success": true, "content": tc.content}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, + caller, "--template-id", "template", "--submit-state", "ON_TIME", "--page", "1", "--page-size", "20"); err == nil { + t.Fatal("malformed submission page was accepted") + } + }) + } +} + +func TestCrossPlatformCoverageAgoalInvalidInputMakesZeroCalls(t *testing.T) { + for _, args := range [][]string{{"--page", "0"}, {"--page-size", "0"}, {"--page-size", "101"}} { + caller := &agoalCoverageCaller{} + err := runAgoalCoverage(t, ObjectTemplateList, caller, args...) + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("invalid args %v error = %#v", args, err) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid args %v called MCP: %#v", args, caller.calls) + } + } + for _, args := range [][]string{ + {"--template-id", "template", "--submit-state", "ON_TIME", "--page", "0"}, + {"--template-id", "template", "--submit-state", "ON_TIME", "--page-size", "101"}, + {"--template-id", "template", "--submit-state", "ON_TIME", "--query-date", "not-a-date"}, + } { + caller := &agoalCoverageCaller{} + if err := runAgoalCoverage(t, ReportSubmitDetail, caller, args...); err == nil { + t.Fatalf("invalid submission args %v were accepted", args) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid submission args %v called MCP: %#v", args, caller.calls) + } + } +} + +func TestCrossPlatformCoverageAgoalReviewedInventory(t *testing.T) { + total := 0 + public := 0 + unavailable := 0 + for _, item := range shortcut.All() { + if item.Service != productAgoal { + continue + } + total++ + if item.Availability == shortcut.AvailabilityUnavailable { + unavailable++ + if !item.Hidden || shortcut.InPublicCatalog(item.Service, item.Command) { + t.Errorf("unavailable %s must remain hidden/non-public", item.Command) + } + continue + } + if shortcut.InPublicCatalog(item.Service, item.Command) { + public++ + if item.Hidden || item.Availability != shortcut.AvailabilityAvailable { + t.Errorf("public %s visibility/availability = %v/%q", item.Command, item.Hidden, item.Availability) + } + if item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("public %s rollout = %q", item.Command, item.OutputRollout) + } + if item.Contract.Result == nil || item.Contract.Interface == nil || strings.TrimSpace(item.Contract.Description) == "" { + t.Errorf("public %s has incomplete Contract/Result", item.Command) + } + if item.Safety.Effect == "" || item.Safety.Risk == "" || item.Safety.Confirmation == "" || item.Safety.Idempotency == "" { + t.Errorf("public %s has incomplete Safety", item.Command) + } + } + } + if total != 16 || public != 5 || unavailable != 11 { + t.Fatalf("agoal inventory total/public/unavailable = %d/%d/%d, want 16/5/11", total, public, unavailable) + } +} + +func TestCrossPlatformCoverageAgoalUnavailableCommandsMakeZeroCalls(t *testing.T) { + for _, spec := range unavailableAgoalSpecs { + declaration, found := agoalRegistered(spec.command) + if !found { + t.Fatalf("registered unavailable command missing: %s", spec.command) + } + args := make([]string, 0) + for _, flag := range declaration.Flags { + if !flag.Required { + continue + } + args = append(args, "--"+flag.Name) + switch flag.Name { + case "scope-type": + args = append(args, "PERSONAL") + case "tracking-period-type": + args = append(args, "MONTHLY") + case "submit-state": + args = append(args, "ON_TIME") + default: + if flag.Type != shortcut.FlagBool { + args = append(args, "fixture") + } + } + } + caller := &agoalCoverageCaller{} + err := runAgoalCoverage(t, declaration, caller, args...) + if err == nil || !strings.Contains(err.Error(), spec.reason) { + t.Errorf("%s unavailable error = %v", spec.command, err) + } + if len(caller.calls) != 0 { + t.Errorf("%s unavailable command called MCP: %#v", spec.command, caller.calls) + } + } +} diff --git a/internal/shortcut/builtin/builtin.go b/internal/shortcut/builtin/builtin.go index b0ddb2f4..880e0b78 100644 --- a/internal/shortcut/builtin/builtin.go +++ b/internal/shortcut/builtin/builtin.go @@ -25,6 +25,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" // Service packages — each registers its shortcuts from init(). + _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/agoal" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/aisearch" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/aitable" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/attendance" diff --git a/internal/shortcut/devapp/devapp.go b/internal/shortcut/devapp/devapp.go index 77519d70..547e634e 100644 --- a/internal/shortcut/devapp/devapp.go +++ b/internal/shortcut/devapp/devapp.go @@ -26,12 +26,13 @@ package devapp import ( "encoding/json" + "fmt" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -64,6 +65,29 @@ func devAppObjectResult(outcomes ...contract.ResultOutcome) *contract.ResultSpec } } +func devAppVerifiedMutationResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"经稳定应用 ID 精确读回验证的开放平台应用变更","properties":{"action":{"type":"string","description":"已执行并验证的变更动作"},"unifiedAppId":{"type":"string","description":"发生变更的稳定统一应用 ID"},"verified":{"type":"boolean","description":"是否已通过精确对象读回或删除后全量排除验证"},"resource":{"type":"object","description":"写后按同一稳定 ID 读回的业务对象;删除动作省略","additionalProperties":true}},"required":["action","unifiedAppId","verified"],"additionalProperties":false}`), + } +} + +func devAppCredentialResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"按稳定应用 ID 严格验证的开放平台应用凭证","properties":{"unifiedAppId":{"type":"string","description":"开放平台统一应用 ID"},"appKey":{"type":"string","description":"应用公开客户端标识"},"clientId":{"type":"string","description":"应用公开客户端标识兼容字段"},"appSecret":{"type":"string","description":"应用敏感客户端密钥"},"clientSecret":{"type":"string","description":"应用敏感客户端密钥兼容字段"},"secret":{"type":"string","description":"应用敏感密钥兼容字段"}},"additionalProperties":true}`), + SensitivePaths: []string{"appSecret", "clientSecret", "secret"}, + } +} + +func devAppMemberListResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格验证并可按稳定 userId 精确筛选的应用成员列表","properties":{"count":{"type":"integer","description":"匹配的成员数量"},"members":{"type":"array","description":"匹配的应用成员","items":{"type":"object","description":"带稳定 userId 的应用成员","additionalProperties":true}}},"required":["count","members"],"additionalProperties":false}`), + SensitivePaths: []string{"members.name"}, + } +} + func devAppPaginatedProjectionResult(collection, description string) *contract.ResultSpec { schema, _ := json.Marshal(map[string]any{ "type": "object", @@ -98,6 +122,298 @@ func devAppCursorPagination() *contract.PaginationSpec { } } +const devAppMaxReadbackPages = 20 + +func devAppResponseError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +func devAppContainers(data map[string]any) []map[string]any { + if data == nil { + return nil + } + containers := []map[string]any{data} + for index := 0; index < len(containers) && index < 12; index++ { + for _, key := range []string{"content", "result", "data"} { + if nested, ok := containers[index][key].(map[string]any); ok { + containers = append(containers, nested) + } + } + } + return containers +} + +func devAppErrorValue(value any) bool { + switch typed := value.(type) { + case nil: + return false + case bool: + return typed + case float64: + return typed != 0 && typed != 200 + case string: + normalized := strings.ToUpper(strings.TrimSpace(typed)) + return normalized != "" && normalized != "0" && normalized != "200" && normalized != "OK" && normalized != "SUCCESS" + case map[string]any: + return len(typed) > 0 + case []any: + return len(typed) > 0 + default: + return true + } +} + +func devAppFirstString(values map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func requireDevAppSuccess(data map[string]any, operation string) (map[string]any, error) { + if len(data) == 0 { + return nil, devAppResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明操作成功或结果确实为空") + } + foundSuccess := false + for _, candidate := range devAppContainers(data) { + if value, present := candidate["success"]; present { + foundSuccess = true + success, ok := value.(bool) + if !ok { + return nil, devAppResponseError(operation, "malformed_success", "响应 success 字段不是布尔值") + } + if !success { + message := devAppFirstString(candidate, "errorMsg", "errorMessage", "message") + if message == "" { + message = "服务明确返回 success=false" + } + return nil, devAppResponseError(operation, "remote_failure", message) + } + } + for _, key := range []string{"error", "errorCode", "error_code"} { + if value, present := candidate[key]; present && devAppErrorValue(value) { + return nil, devAppResponseError(operation, "conflicting_error", fmt.Sprintf("响应 success 与 %s 错误字段冲突", key)) + } + } + } + if !foundSuccess { + return nil, devAppResponseError(operation, "missing_success", "响应缺少 success 布尔终态,无法证明业务成功") + } + return data, nil +} + +func devAppOnlyEnvelopeFields(candidate map[string]any) bool { + for key := range candidate { + switch key { + case "success", "result", "data", "content", "message", "error", "errorCode", "error_code", "code": + default: + return false + } + } + return true +} + +func requireDevAppObject(data map[string]any, operation string) (map[string]any, error) { + data, err := requireDevAppSuccess(data, operation) + if err != nil { + return nil, err + } + containers := devAppContainers(data) + for index := len(containers) - 1; index >= 0; index-- { + candidate := containers[index] + if len(candidate) == 0 || devAppOnlyEnvelopeFields(candidate) { + continue + } + return candidate, nil + } + return nil, devAppResponseError(operation, "missing_business_result", "响应没有可验证的非空业务对象") +} + +func requireDevAppIdentity(object map[string]any, operation string, expected map[string]string) error { + for key, want := range expected { + if want == "" { + continue + } + if got := devAppFirstString(object, key); got == "" { + return devAppResponseError(operation, "readback_id_missing", fmt.Sprintf("读回对象缺少稳定字段 %s", key)) + } else if got != want { + return devAppResponseError(operation, "readback_id_mismatch", fmt.Sprintf("读回对象字段 %s 与请求目标不一致", key)) + } + } + return nil +} + +func requireDevAppFields(object map[string]any, operation string, expected map[string]any) error { + for key, want := range expected { + got, present := object[key] + if !present { + return devAppResponseError(operation, "readback_field_missing", fmt.Sprintf("读回对象缺少请求字段 %s", key)) + } + if fmt.Sprint(got) != fmt.Sprint(want) { + return devAppResponseError(operation, "readback_field_mismatch", fmt.Sprintf("读回对象字段 %s 与请求值不一致", key)) + } + } + return nil +} + +func requireDevAppStringField(object map[string]any, operation, want string, keys ...string) error { + if got := devAppFirstString(object, keys...); got == "" { + return devAppResponseError(operation, "readback_field_missing", fmt.Sprintf("读回对象缺少请求字段 %s", strings.Join(keys, "/"))) + } else if got != want { + return devAppResponseError(operation, "readback_field_mismatch", fmt.Sprintf("读回对象字段 %s 与请求值不一致", strings.Join(keys, "/"))) + } + return nil +} + +func requireDevAppCollection(data map[string]any, operation string, idKeys []string, keys ...string) ([]any, map[string]any, error) { + data, err := requireDevAppSuccess(data, operation) + if err != nil { + return nil, nil, err + } + for _, candidate := range devAppContainers(data) { + for _, key := range keys { + value, present := candidate[key] + if !present { + continue + } + items, ok := value.([]any) + if !ok { + return nil, nil, devAppResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key)) + } + for index, raw := range items { + item, ok := raw.(map[string]any) + if !ok { + return nil, nil, devAppResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是对象", key, index)) + } + if devAppFirstString(item, idKeys...) == "" { + return nil, nil, devAppResponseError(operation, "missing_item_id", fmt.Sprintf("响应 %s[%d] 缺少稳定身份字段", key, index)) + } + } + return items, candidate, nil + } + } + return nil, nil, devAppResponseError(operation, "missing_collection", "成功响应缺少预期集合字段") +} + +func readDevAppObject(rt *shortcut.RuntimeContext, tool string, params map[string]any, expected map[string]string) (map[string]any, map[string]any, error) { + raw, err := rt.CallMCPData(productDevApp, tool, params) + if err != nil { + return nil, nil, err + } + object, err := requireDevAppObject(raw, productDevApp+"/"+tool) + if err != nil { + return nil, nil, err + } + if err := requireDevAppIdentity(object, productDevApp+"/"+tool, expected); err != nil { + return nil, nil, err + } + return raw, object, nil +} + +func outputDevAppObject(rt *shortcut.RuntimeContext, tool string, params map[string]any, expected map[string]string) error { + raw, _, err := readDevAppObject(rt, tool, params, expected) + if err != nil { + return err + } + return rt.OutputForTool(tool, raw) +} + +func devAppWritePreview(rt *shortcut.RuntimeContext, tool string, params map[string]any) (bool, error) { + if !rt.DryRun() { + return false, nil + } + return true, rt.Output(map[string]any{ + "dry_run": true, "executed": false, "tool": tool, "arguments": params, + }) +} + +func callDevAppWrite(rt *shortcut.RuntimeContext, tool string, params map[string]any) (map[string]any, error) { + raw, err := rt.CallMCPWriteDataStrict(productDevApp, tool, params) + if err != nil { + return nil, err + } + return requireDevAppSuccess(raw, productDevApp+"/"+tool) +} + +func verifiedDevAppMutation(action, appID string, resource map[string]any) map[string]any { + result := map[string]any{ + "action": action, "unifiedAppId": appID, "verified": true, + } + if resource != nil { + result["resource"] = resource + } + return result +} + +func verifyDeletedDevApp(rt *shortcut.RuntimeContext, appID, appKey string) error { + if strings.TrimSpace(appKey) == "" { + return devAppResponseError("devapp/delete_dev_app", "missing_readback_selector", "删除前读回缺少 appKey,无法在删除后绑定稳定排除查询") + } + cursor := "" + seen := map[string]bool{} + for page := 0; page < devAppMaxReadbackPages; page++ { + params := map[string]any{"appKey": appKey, "pageSize": 20} + if cursor != "" { + params["cursor"] = cursor + } + raw, err := rt.CallMCPData(productDevApp, "list_dev_app", params) + if err != nil { + return err + } + apps, err := listAppProject(raw) + if err != nil { + return err + } + for _, app := range apps { + if devAppFirstString(app, "unifiedAppId") == appID { + return devAppResponseError("devapp/list_dev_app", "delete_readback_present", "删除后精确列表读回仍包含目标应用") + } + } + projection, err := devAppListProjection(raw, "apps", apps, "devapp/list_dev_app") + if err != nil { + return err + } + more, _ := projection["hasMore"].(bool) + if !more { + return nil + } + next := devAppFirstString(projection, "nextCursor") + if next == "" || next == cursor || seen[next] { + return devAppResponseError("devapp/list_dev_app", "cursor_stall", "删除后排除查询的分页游标未推进") + } + seen[next] = true + cursor = next + } + return devAppResponseError("devapp/list_dev_app", "page_limit", "删除后排除查询达到页数上限,无法证明目标已不存在") +} + +func changeDevAppStatus(rt *shortcut.RuntimeContext, tool, action, expectedStatus string) error { + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} + if previewed, err := devAppWritePreview(rt, tool, params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, tool, params); err != nil { + return err + } + _, resource, err := readDevAppObject(rt, "get_dev_app", params, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + if err := requireDevAppStringField(resource, "devapp/get_dev_app", expectedStatus, "appStatus", "status"); err != nil { + return err + } + return rt.Output(verifiedDevAppMutation(action, appID, resource)) +} + // --------------------------------------------------------------------------- // 应用主体 // --------------------------------------------------------------------------- @@ -182,24 +498,31 @@ var ListApp = shortcut.Shortcut{ if err != nil { return err } - apps := listAppProject(data) - return rt.Output(devAppListProjection(data, "apps", apps)) + apps, err := listAppProject(data) + if err != nil { + return err + } + projection, err := devAppListProjection(data, "apps", apps, "devapp/list_dev_app") + if err != nil { + return err + } + return rt.Output(projection) }, } // listAppProject reshapes list_dev_app into a clean app list // ({unifiedAppId, name, appKey, agentId, status, gmtModified}) — output-projection -// clean output projection. The list container and per-item field names are probed -// defensively across candidate keys, so an unknown/empty shape yields an empty -// list rather than a crash or fabricated data. -func listAppProject(data map[string]any) []map[string]any { - raw := listAppFindList(data) +// clean output projection. The list container and every item are validated +// before projection so unknown response shapes fail closed. +func listAppProject(data map[string]any) ([]map[string]any, error) { + raw, _, err := requireDevAppCollection(data, "devapp/list_dev_app", + []string{"unifiedAppId", "unified_app_id"}, "list", "items", "apps", "appList") + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } + m := item.(map[string]any) row := map[string]any{} if v, ok := listAppFirst(m, "unifiedAppId", "unified_app_id"); ok { row["unifiedAppId"] = v @@ -219,36 +542,9 @@ func listAppProject(data map[string]any) []map[string]any { if v, ok := listAppFirst(m, "gmtModified", "gmt_modified", "modifyTime", "modified_time"); ok { row["gmtModified"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out -} - -// listAppFindList locates the app list payload, tolerating a bare top-level -// array or nesting one level under a common envelope key. -func listAppFindList(data map[string]any) []any { - if data == nil { - return []any{} - } - for _, k := range []string{"list", "items", "apps", "appList", "result", "data"} { - v, ok := data[k] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "apps", "appList", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} + return out, nil } // listAppFirst returns the first present candidate key's value. @@ -302,7 +598,8 @@ var GetApp = shortcut.Shortcut{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_dev_app", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + appID := rt.Str("unified-app-id") + return outputDevAppObject(rt, "get_dev_app", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) }, } @@ -338,6 +635,7 @@ var CreateApp = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +create --name "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "name", Type: shortcut.FlagString, Desc: "应用名称", Required: true}, @@ -352,7 +650,39 @@ var CreateApp = shortcut.Shortcut{ if rt.Changed("icon-media-id") { params["iconMediaId"] = rt.Str("icon-media-id") } - return rt.CallMCP("create_dev_app", params) + if previewed, err := devAppWritePreview(rt, "create_dev_app", params); previewed || err != nil { + return err + } + receipt, err := callDevAppWrite(rt, "create_dev_app", params) + if err != nil { + return err + } + created, err := requireDevAppObject(receipt, "devapp/create_dev_app") + if err != nil { + return err + } + appID := devAppFirstString(created, "unifiedAppId", "unified_app_id") + if appID == "" { + return devAppResponseError("devapp/create_dev_app", "missing_resource_id", "创建回执缺少 unifiedAppId,无法绑定精确读回") + } + _, resource, err := readDevAppObject(rt, "get_dev_app", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + if err := requireDevAppStringField(resource, "devapp/get_dev_app", rt.Str("name"), "name", "appName"); err != nil { + return err + } + if rt.Changed("desc") { + if err := requireDevAppStringField(resource, "devapp/get_dev_app", rt.Str("desc"), "desc", "description"); err != nil { + return err + } + } + if rt.Changed("icon-media-id") { + if err := requireDevAppStringField(resource, "devapp/get_dev_app", rt.Str("icon-media-id"), "iconMediaId", "icon"); err != nil { + return err + } + } + return rt.Output(verifiedDevAppMutation("create", appID, resource)) }, } @@ -388,15 +718,30 @@ var UpdateApp = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +update --unified-app-id "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, - {Name: "name", Type: shortcut.FlagString, Desc: "新的应用名称"}, - {Name: "desc", Type: shortcut.FlagString, Desc: "新的应用描述"}, - {Name: "icon-media-id", Type: shortcut.FlagString, Desc: "新的应用图标 mediaId"}, + {Name: "name", Type: shortcut.FlagString, Desc: "新的应用名称;至少提供一项非空的应用基础信息更新"}, + {Name: "desc", Type: shortcut.FlagString, Desc: "新的应用描述;至少提供一项非空的应用基础信息更新"}, + {Name: "icon-media-id", Type: shortcut.FlagString, Desc: "新的应用图标 mediaId;至少提供一项非空的应用基础信息更新"}, + }, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, + Flags: []string{"name", "desc", "icon-media-id"}, + Description: "至少提供一项非空的应用基础信息更新", + }}, + Validate: func(rt *shortcut.RuntimeContext) error { + for _, name := range []string{"name", "desc", "icon-media-id"} { + if rt.Changed(name) && rt.Str(name) != "" { + return nil + } + } + return apperrors.NewValidation("至少提供一项待更新字段:--name、--desc 或 --icon-media-id") }, Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")} + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} if rt.Changed("name") { params["name"] = rt.Str("name") } @@ -406,7 +751,26 @@ var UpdateApp = shortcut.Shortcut{ if rt.Changed("icon-media-id") { params["iconMediaId"] = rt.Str("icon-media-id") } - return rt.CallMCP("update_dev_app", params) + if previewed, err := devAppWritePreview(rt, "update_dev_app", params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, "update_dev_app", params); err != nil { + return err + } + _, resource, err := readDevAppObject(rt, "get_dev_app", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + for flag, keys := range map[string][]string{ + "name": {"name", "appName"}, "desc": {"desc", "description"}, "icon-media-id": {"iconMediaId", "icon"}, + } { + if rt.Changed(flag) { + if err := requireDevAppStringField(resource, "devapp/get_dev_app", rt.Str(flag), keys...); err != nil { + return err + } + } + } + return rt.Output(verifiedDevAppMutation("update", appID, resource)) }, } @@ -442,12 +806,32 @@ var DeleteApp = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +delete --unified-app-id "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("delete_dev_app", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} + if previewed, err := devAppWritePreview(rt, "delete_dev_app", params); previewed || err != nil { + return err + } + _, existing, err := readDevAppObject(rt, "get_dev_app", params, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + appKey := devAppFirstString(existing, "appKey", "clientId") + if appKey == "" { + return devAppResponseError("devapp/get_dev_app", "missing_readback_selector", "删除前读回缺少 appKey,拒绝进入不可逆写入") + } + if _, err := callDevAppWrite(rt, "delete_dev_app", params); err != nil { + return err + } + if err := verifyDeletedDevApp(rt, appID, appKey); err != nil { + return err + } + return rt.Output(verifiedDevAppMutation("delete", appID, nil)) }, } @@ -483,12 +867,13 @@ var EnableApp = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +enable --unified-app-id "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("enable_dev_app", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + return changeDevAppStatus(rt, "enable_dev_app", "enable", "normal") }, } @@ -524,12 +909,13 @@ var DisableApp = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +disable --unified-app-id "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("disable_dev_app", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + return changeDevAppStatus(rt, "disable_dev_app", "disable", "disabled") }, } @@ -541,11 +927,48 @@ var GetCredentials = shortcut.Shortcut{ Description: "读取开放平台应用凭证", Intent: "当你需要拿到某应用的鉴权凭证(如 clientId/AppKey、clientSecret/AppSecret)以便在代码或调试中调用开放平台接口时使用;输入 unifiedAppId,返回该应用的凭证信息。", Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "medium", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "devapp", + Name: "shortcut_credentials_get", + CanonicalPath: "devapp.shortcut_credentials_get", + CLIPath: "devapp +credentials-get", + PrimaryCLIPath: "devapp +credentials-get", + }, + Description: "读取开放平台应用凭证", + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "Reviewed adapter requires an explicit success marker, a non-empty credential object, exact unifiedAppId, a public client identifier, and a non-empty secret before unified output; secret fields are declared sensitive.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "读取开放平台应用凭证", + UseWhen: []string{"当你需要拿到某应用的鉴权凭证(如 clientId/AppKey、clientSecret/AppSecret)以便在代码或调试中调用开放平台接口时使用;输入 unifiedAppId,返回该应用的凭证信息。"}, + AvoidWhen: []string{"只需应用元数据时使用 +get;不要把返回密钥写入文档、日志、示例或聊天正文"}, + Examples: []string{"dws devapp +credentials-get --unified-app-id "}, + }, + Result: devAppCredentialResult(), + }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_dev_app_credentials", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + appID := rt.Str("unified-app-id") + _, credentials, err := readDevAppObject(rt, "get_dev_app_credentials", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + if devAppFirstString(credentials, "appKey", "clientId") == "" { + return devAppResponseError("devapp/get_dev_app_credentials", "missing_client_id", "凭证响应缺少非空 appKey/clientId") + } + if devAppFirstString(credentials, "appSecret", "clientSecret", "secret") == "" { + return devAppResponseError("devapp/get_dev_app_credentials", "missing_client_secret", "凭证响应缺少非空 appSecret/clientSecret") + } + return rt.Output(credentials) }, } @@ -594,7 +1017,8 @@ var WebappGet = shortcut.Shortcut{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_extension_webapp_config", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + appID := rt.Str("unified-app-id") + return outputDevAppObject(rt, "get_extension_webapp_config", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) }, } @@ -630,16 +1054,31 @@ var WebappConfig = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +webapp-config --unified-app-id "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, - {Name: "h5-page-type", Type: shortcut.FlagString, Desc: "网页应用生效端/页面类型"}, - {Name: "homepage-url", Type: shortcut.FlagString, Desc: "移动端首页地址"}, - {Name: "pc-homepage-url", Type: shortcut.FlagString, Desc: "PC 端首页地址"}, - {Name: "omp-url", Type: shortcut.FlagString, Desc: "管理后台地址"}, + {Name: "h5-page-type", Type: shortcut.FlagString, Desc: "网页应用生效端/页面类型;至少提供一项非空的网页应用配置"}, + {Name: "homepage-url", Type: shortcut.FlagString, Desc: "移动端首页地址;至少提供一项非空的网页应用配置"}, + {Name: "pc-homepage-url", Type: shortcut.FlagString, Desc: "PC 端首页地址;至少提供一项非空的网页应用配置"}, + {Name: "omp-url", Type: shortcut.FlagString, Desc: "管理后台地址;至少提供一项非空的网页应用配置"}, + }, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, + Flags: []string{"h5-page-type", "homepage-url", "pc-homepage-url", "omp-url"}, + Description: "至少提供一项非空的网页应用配置", + }}, + Validate: func(rt *shortcut.RuntimeContext) error { + for _, name := range []string{"h5-page-type", "homepage-url", "pc-homepage-url", "omp-url"} { + if rt.Changed(name) && rt.Str(name) != "" { + return nil + } + } + return apperrors.NewValidation("至少提供一项网页应用配置:--h5-page-type、--homepage-url、--pc-homepage-url 或 --omp-url") }, Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")} + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} if rt.Changed("h5-page-type") { params["h5PageType"] = rt.Str("h5-page-type") } @@ -652,7 +1091,27 @@ var WebappConfig = shortcut.Shortcut{ if rt.Changed("omp-url") { params["ompUrl"] = rt.Str("omp-url") } - return rt.CallMCP("set_extension_webapp_config", params) + if previewed, err := devAppWritePreview(rt, "set_extension_webapp_config", params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, "set_extension_webapp_config", params); err != nil { + return err + } + _, resource, err := readDevAppObject(rt, "get_extension_webapp_config", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + for flag, keys := range map[string][]string{ + "h5-page-type": {"h5PageType"}, "homepage-url": {"homepageUrl"}, + "pc-homepage-url": {"pcHomepageUrl"}, "omp-url": {"ompUrl"}, + } { + if rt.Changed(flag) { + if err := requireDevAppStringField(resource, "devapp/get_extension_webapp_config", rt.Str(flag), keys...); err != nil { + return err + } + } + } + return rt.Output(verifiedDevAppMutation("webapp_config", appID, resource)) }, } @@ -725,24 +1184,33 @@ var PermissionList = shortcut.Shortcut{ if err != nil { return err } - permissions := permissionListProject(data) - return rt.Output(devAppListProjection(data, "permissions", permissions)) + permissions, err := permissionListProject(data) + if err != nil { + return err + } + projection, err := devAppListProjection(data, "permissions", permissions, "devapp/list_dev_app_permissions") + if err != nil { + return err + } + return rt.Output(projection) }, } // permissionListProject reshapes list_dev_app_permissions into a clean // permission-point list ({scopeValue, scopeName, apiName, authStatus, scopeType}) // — clean output projection. The list container and per-item field -// names are probed defensively across candidate keys, so an unknown/empty shape -// yields an empty list rather than a crash or fabricated data. -func permissionListProject(data map[string]any) []map[string]any { - raw := permissionListFindList(data) +// names are probed across reviewed candidate keys, while missing, mistyped, or +// malformed collections fail closed. +func permissionListProject(data map[string]any) ([]map[string]any, error) { + raw, _, err := requireDevAppCollection(data, "devapp/list_dev_app_permissions", + []string{"scopeValue", "scope_value", "permissionCode", "code"}, + "list", "items", "permissions", "permissionList", "scopes") + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } + m := item.(map[string]any) row := map[string]any{} if v, ok := permissionListFirst(m, "scopeValue", "scope_value", "permissionCode", "code"); ok { row["scopeValue"] = v @@ -759,36 +1227,9 @@ func permissionListProject(data map[string]any) []map[string]any { if v, ok := permissionListFirst(m, "scopeType", "scope_type"); ok { row["scopeType"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out -} - -// permissionListFindList locates the permission list payload, tolerating a bare -// top-level array or nesting one level under a common envelope key. -func permissionListFindList(data map[string]any) []any { - if data == nil { - return []any{} - } - for _, k := range []string{"list", "items", "permissions", "permissionList", "scopes", "result", "data"} { - v, ok := data[k] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "permissions", "permissionList", "scopes", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} + return out, nil } // permissionListFirst returns the first present candidate key's value. @@ -877,24 +1318,112 @@ var MemberList = shortcut.Shortcut{ AgentSummary: "查询开放平台应用成员", UseWhen: []string{"当你要查看某应用有哪些成员及其角色(如谁是开发者/管理员),用于核对协作人员或权限归属时使用;输入 unifiedAppId,返回成员列表。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws devapp +member-list --unified-app-id "}, + Examples: []string{"dws devapp +member-list --unified-app-id --user-id "}, }, + Result: devAppMemberListResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + {Name: "user-id", Type: shortcut.FlagString, Desc: "可选稳定 userId;由 Shortcut 在严格验证完整成员数组后做精确等值筛选"}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("list_dev_app_members", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + raw, err := rt.CallMCPData(productDevApp, "list_dev_app_members", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + if err != nil { + return err + } + members, _, err := requireDevAppCollection(raw, "devapp/list_dev_app_members", []string{"userId", "user_id"}, "members", "items", "list") + if err != nil { + return err + } + wantUser := strings.TrimSpace(rt.Str("user-id")) + out := projectDevAppMembers(members, wantUser) + return rt.Output(map[string]any{"count": len(out), "members": out}) }, } +func projectDevAppMembers(members []any, wantUser string) []map[string]any { + out := make([]map[string]any, 0, len(members)) + for _, value := range members { + member := value.(map[string]any) + if wantUser != "" && devAppFirstString(member, "userId", "user_id") != wantUser { + continue + } + out = append(out, member) + } + return out +} + +func readDevAppMembers(rt *shortcut.RuntimeContext, appID string) ([]any, error) { + raw, err := rt.CallMCPData(productDevApp, "list_dev_app_members", map[string]any{"unifiedAppId": appID}) + if err != nil { + return nil, err + } + members, _, err := requireDevAppCollection(raw, "devapp/list_dev_app_members", + []string{"userId", "user_id"}, "members", "items", "list") + return members, err +} + +func verifyDevAppMembers(rt *shortcut.RuntimeContext, appID string, userIDs []string, memberType string, present bool) error { + members, err := readDevAppMembers(rt, appID) + if err != nil { + return err + } + want := make(map[string]bool, len(userIDs)) + for _, userID := range userIDs { + want[userID] = true + } + found := make(map[string]int, len(userIDs)) + for _, value := range members { + member := value.(map[string]any) + userID := devAppFirstString(member, "userId", "user_id") + if !want[userID] { + continue + } + found[userID]++ + if found[userID] > 1 { + return devAppResponseError("devapp/list_dev_app_members", "duplicate_readback_identity", "成员读回包含重复稳定 userId,无法证明唯一终态") + } + if present { + if err := requireDevAppStringField(member, "devapp/list_dev_app_members", memberType, "memberType", "member_type"); err != nil { + return err + } + } + } + for _, userID := range userIDs { + if present && found[userID] != 1 { + return devAppResponseError("devapp/list_dev_app_members", "member_readback_missing", "添加后成员列表未精确包含全部目标 userId") + } + if !present && found[userID] != 0 { + return devAppResponseError("devapp/list_dev_app_members", "member_remove_readback_present", "移除后成员列表仍包含目标 userId") + } + } + return nil +} + +func validatedDevAppMemberInputs(rt *shortcut.RuntimeContext) ([]string, string, error) { + userIDs, err := validatedDevAppValues(rt.StrSlice("user-ids"), "--user-ids") + if err != nil { + return nil, "", err + } + memberType, err := validatedDevAppMemberType(rt.Str("member-type")) + return userIDs, memberType, err +} + +func validatedDevAppMemberType(value string) (string, error) { + memberType := strings.TrimSpace(value) + if memberType == "" { + return "", apperrors.NewValidation("--member-type 不能为空") + } + return memberType, nil +} + // MemberAdd maps helper `add_dev_app_members`. var MemberAdd = shortcut.Shortcut{ Service: "devapp", Command: "+member-add", Product: productDevApp, Description: "添加开放平台应用成员", - Intent: "当你要给某应用增加协作人员(如把某人加为开发者)时使用;传入 unifiedAppId、userId 列表和成员类型(如 DEVELOPER),会实际把这些人加入应用成员并赋予对应角色。", + Intent: "当你要给某应用增加协作人员并要求按稳定 userId 逐项确认成员角色已写回时使用;传入 unifiedAppId、userId 列表和成员类型(如 DEVELOPER)。", Risk: shortcut.RiskWrite, Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", @@ -916,23 +1445,45 @@ var MemberAdd = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "添加开放平台应用成员", - UseWhen: []string{"当你要给某应用增加协作人员(如把某人加为开发者)时使用;传入 unifiedAppId、userId 列表和成员类型(如 DEVELOPER),会实际把这些人加入应用成员并赋予对应角色。"}, + UseWhen: []string{"当你要给某应用增加协作人员并要求按稳定 userId 逐项确认成员角色已写回时使用;传入 unifiedAppId、userId 列表和成员类型(如 DEVELOPER)。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +member-add --unified-app-id --user-ids --member-type "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, - {Name: "user-ids", Type: shortcut.FlagStringSlice, Desc: "成员 userId 列表", Required: true}, - {Name: "member-type", Type: shortcut.FlagString, Desc: "成员类型,如 DEVELOPER", Required: true}, + {Name: "user-ids", Type: shortcut.FlagStringSlice, Desc: "成员 userId 列表,不能为空且不能重复", Required: true}, + {Name: "member-type", Type: shortcut.FlagString, Desc: "成员类型,如 DEVELOPER,不能为空", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"user-ids"}, Description: "userId 列表不能为空且不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"member-type"}, Description: "成员类型不能为空"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + _, _, err := validatedDevAppMemberInputs(rt) + return err }, Execute: func(rt *shortcut.RuntimeContext) error { + userIDs, memberType, _ := validatedDevAppMemberInputs(rt) + appID := rt.Str("unified-app-id") params := map[string]any{ - "unifiedAppId": rt.Str("unified-app-id"), - "userIds": rt.StrSlice("user-ids"), - "memberType": rt.Str("member-type"), + "unifiedAppId": appID, + "userIds": userIDs, + "memberType": memberType, } - return rt.CallMCP("add_dev_app_members", params) + if previewed, err := devAppWritePreview(rt, "add_dev_app_members", params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, "add_dev_app_members", params); err != nil { + return err + } + if err := verifyDevAppMembers(rt, appID, userIDs, memberType, true); err != nil { + return err + } + return rt.Output(verifiedDevAppMutation("member_add", appID, map[string]any{ + "count": len(userIDs), "memberType": memberType, + })) }, } @@ -942,7 +1493,7 @@ var MemberRemove = shortcut.Shortcut{ Command: "+member-remove", Product: productDevApp, Description: "移除开放平台应用成员", - Intent: "当某人离职或不再参与、你要取消其对应用的访问/协作权限时使用;传入 unifiedAppId、userId 列表和成员类型,会实际把这些人从应用成员中移除。", + Intent: "当某人不再参与、你要取消其应用成员身份,并要求按稳定 userId 逐项确认目标已不在成员列表中时使用;传入 unifiedAppId、userId 列表和成员类型。", Risk: shortcut.RiskHighWrite, Safety: contract.SafetySpec{ Effect: "destructive", Risk: "high", @@ -964,23 +1515,45 @@ var MemberRemove = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "移除开放平台应用成员", - UseWhen: []string{"当某人离职或不再参与、你要取消其对应用的访问/协作权限时使用;传入 unifiedAppId、userId 列表和成员类型,会实际把这些人从应用成员中移除。"}, + UseWhen: []string{"当某人不再参与、你要取消其应用成员身份,并要求按稳定 userId 逐项确认目标已不在成员列表中时使用;传入 unifiedAppId、userId 列表和成员类型。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +member-remove --unified-app-id --user-ids --member-type "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, - {Name: "user-ids", Type: shortcut.FlagStringSlice, Desc: "成员 userId 列表", Required: true}, - {Name: "member-type", Type: shortcut.FlagString, Desc: "成员类型,如 DEVELOPER", Required: true}, + {Name: "user-ids", Type: shortcut.FlagStringSlice, Desc: "成员 userId 列表,不能为空且不能重复", Required: true}, + {Name: "member-type", Type: shortcut.FlagString, Desc: "成员类型,如 DEVELOPER,不能为空", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"user-ids"}, Description: "userId 列表不能为空且不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"member-type"}, Description: "成员类型不能为空"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + _, _, err := validatedDevAppMemberInputs(rt) + return err }, Execute: func(rt *shortcut.RuntimeContext) error { + userIDs, memberType, _ := validatedDevAppMemberInputs(rt) + appID := rt.Str("unified-app-id") params := map[string]any{ - "unifiedAppId": rt.Str("unified-app-id"), - "userIds": rt.StrSlice("user-ids"), - "memberType": rt.Str("member-type"), + "unifiedAppId": appID, + "userIds": userIDs, + "memberType": memberType, } - return rt.CallMCP("remove_dev_app_members", params) + if previewed, err := devAppWritePreview(rt, "remove_dev_app_members", params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, "remove_dev_app_members", params); err != nil { + return err + } + if err := verifyDevAppMembers(rt, appID, userIDs, memberType, false); err != nil { + return err + } + return rt.Output(verifiedDevAppMutation("member_remove", appID, map[string]any{ + "count": len(userIDs), "memberType": memberType, + })) }, } @@ -1062,7 +1635,8 @@ var RobotGet = shortcut.Shortcut{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_extension_robot_config", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + appID := rt.Str("unified-app-id") + return outputDevAppObject(rt, "get_extension_robot_config", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) }, } @@ -1075,21 +1649,63 @@ var RobotConfig = shortcut.Shortcut{ Description: "创建或更新现有应用的机器人配置(upsert)", Intent: "当你要为应用开通机器人或调整其机器人设置(改名称/简介/图标、设消息与事件回调地址、切换 HTTPS/STREAM/AISKILL 模式、配技能、是否自动加权限或关 SSL 校验)时使用;按 unifiedAppId 以 upsert 方式写入机器人配置,会实际生效。", Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "unknown", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "devapp", Name: "shortcut_robot_config", + CanonicalPath: "devapp.shortcut_robot_config", CLIPath: "devapp +robot-config", PrimaryCLIPath: "devapp +robot-config", + }, + Description: "创建或更新现有应用的机器人配置并精确读回", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", + Reason: "Reviewed adapter rejects an empty update, requires terminal success, then reads the same unifiedAppId and compares every changed scalar field before returning verified output.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "创建或更新现有应用的机器人配置(upsert)", + UseWhen: []string{"当你要为应用开通机器人或调整其机器人设置(改名称/简介/图标、设消息与事件回调地址、切换 HTTPS/STREAM/AISKILL 模式、配技能、是否自动加权限或关 SSL 校验)时使用;按 unifiedAppId 以 upsert 方式写入机器人配置,会实际生效。"}, + AvoidWhen: []string{"只需查看现状使用 +robot-get;仅切换已配置机器人的在线状态使用 +robot-enable 或 +robot-disable"}, + Examples: []string{"dws devapp +robot-config --unified-app-id --name --mode STREAM"}, + }, + Result: devAppVerifiedMutationResult(), + }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, - {Name: "name", Type: shortcut.FlagString, Desc: "机器人名称"}, - {Name: "brief", Type: shortcut.FlagString, Desc: "机器人简介"}, - {Name: "desc", Type: shortcut.FlagString, Desc: "机器人描述"}, - {Name: "icon-media-id", Type: shortcut.FlagString, Desc: "机器人图标 mediaId"}, - {Name: "outgoing-url", Type: shortcut.FlagString, Desc: "消息回调地址"}, - {Name: "event-callback-url", Type: shortcut.FlagString, Desc: "事件回调地址"}, - {Name: "mode", Type: shortcut.FlagString, Enum: []string{"HTTPS", "STREAM", "AISKILL"}, Desc: "机器人模式:HTTPS / STREAM / AISKILL"}, - {Name: "skills", Type: shortcut.FlagStringSlice, Desc: "技能列表"}, - {Name: "add-scope", Type: shortcut.FlagBool, Desc: "是否自动添加机器人相关权限"}, - {Name: "disable-ssl-verify", Type: shortcut.FlagBool, Desc: "回调地址是否关闭 SSL 校验"}, + {Name: "name", Type: shortcut.FlagString, Desc: "机器人名称;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "brief", Type: shortcut.FlagString, Desc: "机器人简介;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "desc", Type: shortcut.FlagString, Desc: "机器人描述;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "icon-media-id", Type: shortcut.FlagString, Desc: "机器人图标 mediaId;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "outgoing-url", Type: shortcut.FlagString, Desc: "消息回调地址;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "event-callback-url", Type: shortcut.FlagString, Desc: "事件回调地址;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "mode", Type: shortcut.FlagString, Enum: []string{"HTTPS", "STREAM", "AISKILL"}, Desc: "机器人模式:HTTPS / STREAM / AISKILL;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "skills", Type: shortcut.FlagStringSlice, Desc: "技能列表;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "add-scope", Type: shortcut.FlagBool, Desc: "是否自动添加机器人相关权限;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "disable-ssl-verify", Type: shortcut.FlagBool, Desc: "回调地址是否关闭 SSL 校验;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + }, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, + Flags: []string{"name", "brief", "desc", "icon-media-id", "outgoing-url", "event-callback-url", "mode", "skills", "add-scope", "disable-ssl-verify"}, + Description: "至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置", + }}, + Validate: func(rt *shortcut.RuntimeContext) error { + for _, name := range []string{"name", "brief", "desc", "icon-media-id", "outgoing-url", "event-callback-url", "mode"} { + if rt.Changed(name) && strings.TrimSpace(rt.Str(name)) != "" { + return nil + } + } + if rt.Changed("skills") && len(rt.StrSlice("skills")) > 0 { + return nil + } + if rt.Changed("add-scope") || rt.Changed("disable-ssl-verify") { + return nil + } + return apperrors.NewValidation("至少提供一项非空机器人配置") }, Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")} + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} if rt.Changed("name") { params["name"] = rt.Str("name") } @@ -1120,7 +1736,45 @@ var RobotConfig = shortcut.Shortcut{ if rt.Changed("disable-ssl-verify") { params["disableSSLVerify"] = rt.Bool("disable-ssl-verify") } - return rt.CallMCP("set_extension_robot_config", params) + if previewed, err := devAppWritePreview(rt, "set_extension_robot_config", params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, "set_extension_robot_config", params); err != nil { + return err + } + _, resource, err := readDevAppObject(rt, "get_extension_robot_config", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + for flag, keys := range map[string][]string{ + "name": {"name"}, "brief": {"brief"}, "desc": {"desc", "description"}, + "icon-media-id": {"iconMediaId"}, "outgoing-url": {"outgoingUrl"}, + "event-callback-url": {"eventCallbackUrl"}, "mode": {"mode"}, + } { + if !rt.Changed(flag) { + continue + } + want := rt.Str(flag) + if flag == "mode" { + want = strings.ToUpper(want) + } + if err := requireDevAppStringField(resource, "devapp/get_extension_robot_config", want, keys...); err != nil { + return err + } + } + for flag, key := range map[string]string{"add-scope": "addScope", "disable-ssl-verify": "disableSSLVerify"} { + if rt.Changed(flag) { + if err := requireDevAppFields(resource, "devapp/get_extension_robot_config", map[string]any{key: rt.Bool(flag)}); err != nil { + return err + } + } + } + if rt.Changed("skills") { + if err := requireDevAppFields(resource, "devapp/get_extension_robot_config", map[string]any{"skills": rt.StrSlice("skills")}); err != nil { + return err + } + } + return rt.Output(verifiedDevAppMutation("robot_config", appID, resource)) }, } @@ -1130,13 +1784,32 @@ var RobotEnable = shortcut.Shortcut{ Command: "+robot-enable", Product: productDevApp, Description: "启用现有应用机器人能力(纯启用,无需配置字段)", - Intent: "当应用的机器人能力已配置但处于关闭状态、你只想把它打开生效时使用;仅传 unifiedAppId 即可,会实际启用该应用的机器人能力,无需再传配置字段。", + Intent: "当应用已经具有可启用的机器人配置、你要把它切换到 ONLINE 时使用;写后会读取同一 unifiedAppId 并只在 robotStatus=ONLINE 时成功。", Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "devapp", Name: "shortcut_robot_enable", + CanonicalPath: "devapp.shortcut_robot_enable", CLIPath: "devapp +robot-enable", PrimaryCLIPath: "devapp +robot-enable", + }, + Description: "启用已配置的应用机器人并读回 ONLINE 终态", + Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "Reviewed adapter requires terminal success and exact get_extension_robot_config readback for the same unifiedAppId with robotStatus=ONLINE."}, + Selection: contract.SelectionSpec{ + AgentSummary: "启用现有应用机器人能力(纯启用,无需配置字段)", + UseWhen: []string{"当应用已经具有可启用的机器人配置、你要把它切换到 ONLINE 时使用;写后会读取同一 unifiedAppId 并只在 robotStatus=ONLINE 时成功。"}, + AvoidWhen: []string{"需要修改机器人字段时使用 +robot-config;只查看状态使用 +robot-get"}, + Examples: []string{"dws devapp +robot-enable --unified-app-id "}, + }, + Result: devAppVerifiedMutationResult(), + }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("enable_dev_app_robot", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + return changeDevAppRobotStatus(rt, "enable_dev_app_robot", "robot_enable", "ONLINE") }, } @@ -1146,16 +1819,58 @@ var RobotDisable = shortcut.Shortcut{ Command: "+robot-disable", Product: productDevApp, Description: "停用现有应用的机器人能力", - Intent: "当你要临时关闭某应用的机器人(不再收发机器人消息)但保留其配置时使用;传入 unifiedAppId 会实际停用机器人能力,可日后再启用恢复。", + Intent: "当你要下线某应用的机器人、不再收发机器人消息时使用;当前平台读回终态为 UNCONFIGURED,因此重新上线前可能需要先用 +robot-config 恢复配置。", Risk: shortcut.RiskHighWrite, + Safety: contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "devapp", Name: "shortcut_robot_disable", + CanonicalPath: "devapp.shortcut_robot_disable", CLIPath: "devapp +robot-disable", PrimaryCLIPath: "devapp +robot-disable", + }, + Description: "下线应用机器人并读回 UNCONFIGURED 终态", + Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "Reviewed adapter requires terminal success and exact get_extension_robot_config readback for the same unifiedAppId with robotStatus=UNCONFIGURED; it does not promise that configuration remains reusable."}, + Selection: contract.SelectionSpec{ + AgentSummary: "停用现有应用的机器人能力", + UseWhen: []string{"当你要下线某应用的机器人、不再收发机器人消息时使用;当前平台读回终态为 UNCONFIGURED,因此重新上线前可能需要先用 +robot-config 恢复配置。"}, + AvoidWhen: []string{"只需临时停用但必须保证配置可原样恢复时不要使用;当前下游没有该保留语义"}, + Examples: []string{"dws devapp +robot-disable --unified-app-id "}, + }, + Result: devAppVerifiedMutationResult(), + }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("disable_dev_app_robot", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + return changeDevAppRobotStatus(rt, "disable_dev_app_robot", "robot_disable", "UNCONFIGURED") }, } +func changeDevAppRobotStatus(rt *shortcut.RuntimeContext, tool, action, wantStatus string) error { + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} + if previewed, err := devAppWritePreview(rt, tool, params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, tool, params); err != nil { + return err + } + _, resource, err := readDevAppObject(rt, "get_extension_robot_config", params, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + gotStatus := strings.ToUpper(devAppFirstString(resource, "robotStatus", "robot_status", "status")) + if gotStatus == "" { + return devAppResponseError("devapp/get_extension_robot_config", "readback_status_missing", "机器人状态读回缺少 robotStatus") + } + if gotStatus != wantStatus { + return devAppResponseError("devapp/get_extension_robot_config", "readback_status_mismatch", fmt.Sprintf("机器人状态读回为 %s,预期 %s", gotStatus, wantStatus)) + } + return rt.Output(verifiedDevAppMutation(action, appID, resource)) +} + // --------------------------------------------------------------------------- // 事件订阅 // --------------------------------------------------------------------------- @@ -1165,8 +1880,8 @@ var EventList = shortcut.Shortcut{ Service: "devapp", Command: "+event-list", Product: productDevApp, - Description: "查询应用已订阅的事件列表", - Intent: "当你要确认某应用当前订阅了哪些事件回调(用于排查漏收事件、或退订前先查事件码)时使用;输入 unifiedAppId,可按事件码/名称关键词过滤并分页,返回已订阅事件列表。", + Description: "查询应用可用事件目录与订阅状态", + Intent: "当你要查某应用可用的事件码、事件名称及当前订阅状态(用于选择订阅项、排查漏收事件或退订前核对)时使用;输入 unifiedAppId,可按关键词过滤并游标分页。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -1180,15 +1895,15 @@ var EventList = shortcut.Shortcut{ CLIPath: "devapp +event-list", PrimaryCLIPath: "devapp +event-list", }, - Description: "查询应用已订阅的事件列表", + Description: "查询应用可用事件目录与订阅状态", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", }, Selection: contract.SelectionSpec{ - AgentSummary: "查询应用已订阅的事件列表", - UseWhen: []string{"当你要确认某应用当前订阅了哪些事件回调(用于排查漏收事件、或退订前先查事件码)时使用;输入 unifiedAppId,可按事件码/名称关键词过滤并分页,返回已订阅事件列表。"}, + AgentSummary: "查询应用可用事件目录与订阅状态", + UseWhen: []string{"当你要查某应用可用的事件码、事件名称及当前订阅状态(用于选择订阅项、排查漏收事件或退订前核对)时使用;输入 unifiedAppId,可按关键词过滤并游标分页。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +event-list --unified-app-id "}, }, @@ -1209,24 +1924,31 @@ var EventList = shortcut.Shortcut{ if err != nil { return err } - events := eventListProject(data) - return rt.Output(devAppListProjection(data, "events", events)) + events, err := eventListProject(data) + if err != nil { + return err + } + projection, err := devAppListProjection(data, "events", events, "devapp/list_dev_app_events") + if err != nil { + return err + } + return rt.Output(projection) }, } // eventListProject reshapes list_dev_app_events into a clean subscribed-event // list ({eventCode, eventName, status, gmtModified}) — output-projection -// clean output projection. The list container and per-item field names are probed -// defensively across candidate keys, so an unknown/empty shape yields an empty -// list rather than a crash or fabricated data. -func eventListProject(data map[string]any) []map[string]any { - raw := eventListFindList(data) +// clean output projection. The list container and every item are validated +// before projection so unknown response shapes fail closed. +func eventListProject(data map[string]any) ([]map[string]any, error) { + raw, _, err := requireDevAppCollection(data, "devapp/list_dev_app_events", + []string{"eventCode", "event_code", "code"}, "list", "items", "events", "eventList") + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } + m := item.(map[string]any) row := map[string]any{} if v, ok := eventListFirst(m, "eventCode", "event_code", "code"); ok { row["eventCode"] = v @@ -1240,36 +1962,9 @@ func eventListProject(data map[string]any) []map[string]any { if v, ok := eventListFirst(m, "gmtModified", "gmt_modified", "modifyTime", "modified_time"); ok { row["gmtModified"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out -} - -// eventListFindList locates the event list payload, tolerating a bare top-level -// array or nesting one level under a common envelope key. -func eventListFindList(data map[string]any) []any { - if data == nil { - return []any{} - } - for _, k := range []string{"list", "items", "events", "eventList", "result", "data"} { - v, ok := data[k] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "events", "eventList", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} + return out, nil } // eventListFirst returns the first present candidate key's value. @@ -1290,19 +1985,127 @@ var EventSubscribe = shortcut.Shortcut{ Description: "订阅应用事件回调", Intent: "当你要让应用开始接收某些事件的回调推送(如通讯录变更、审批事件等)时使用;传入 unifiedAppId 和事件码列表,会实际为该应用登记这些事件订阅。", Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "devapp", Name: "shortcut_event_subscribe", + CanonicalPath: "devapp.shortcut_event_subscribe", CLIPath: "devapp +event-subscribe", PrimaryCLIPath: "devapp +event-subscribe", + }, + Description: "订阅应用事件并逐项精确读回", + Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "Reviewed adapter rejects empty or duplicate event codes, requires terminal success, then traverses the bounded event cursor and verifies every requested eventCode under the same unifiedAppId."}, + Selection: contract.SelectionSpec{ + AgentSummary: "订阅应用事件回调", + UseWhen: []string{"当你要让应用开始接收某些事件的回调推送(如通讯录变更、审批事件等)时使用;传入 unifiedAppId 和事件码列表,会实际为该应用登记这些事件订阅。"}, + AvoidWhen: []string{"只需查看现有订阅时暂用 dev app event list 原子命令;+event-list 缺少零结果分页终止事实,退订也无法证明实际移除"}, + Examples: []string{"dws devapp +event-subscribe --unified-app-id --event-codes "}, + }, + Result: devAppVerifiedMutationResult(), + }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, - {Name: "event-codes", Type: shortcut.FlagStringSlice, Desc: "事件码列表", Required: true}, + {Name: "event-codes", Type: shortcut.FlagStringSlice, Desc: "事件码列表至少包含一项非空且互不重复的 eventCode", Required: true}, + }, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, + Flags: []string{"event-codes"}, + Description: "事件码列表至少包含一项非空且互不重复的 eventCode", + }}, + Validate: func(rt *shortcut.RuntimeContext) error { + _, err := validatedDevAppValues(rt.StrSlice("event-codes"), "--event-codes") + return err }, Execute: func(rt *shortcut.RuntimeContext) error { + // Validate already rejects empty and duplicate values before Execute. + codes, _ := validatedDevAppValues(rt.StrSlice("event-codes"), "--event-codes") + appID := rt.Str("unified-app-id") params := map[string]any{ - "unifiedAppId": rt.Str("unified-app-id"), - "eventCodes": rt.StrSlice("event-codes"), + "unifiedAppId": appID, + "eventCodes": codes, } - return rt.CallMCP("subscribe_dev_app_events", params) + if previewed, err := devAppWritePreview(rt, "subscribe_dev_app_events", params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, "subscribe_dev_app_events", params); err != nil { + return err + } + if err := verifyDevAppEventCodes(rt, appID, codes); err != nil { + return err + } + return rt.Output(verifiedDevAppMutation("event_subscribe", appID, map[string]any{"eventCodes": codes})) }, } +func validatedDevAppValues(values []string, flag string) ([]string, error) { + out := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, raw := range values { + value := strings.TrimSpace(raw) + if value == "" { + return nil, apperrors.NewValidation(flag + " 不能包含空值") + } + if seen[value] { + return nil, apperrors.NewValidation(flag + " 不能包含重复值") + } + seen[value] = true + out = append(out, value) + } + if len(out) == 0 { + return nil, apperrors.NewValidation(flag + " 至少需要一个非空值") + } + return out, nil +} + +func verifyDevAppEventCodes(rt *shortcut.RuntimeContext, appID string, wantCodes []string) error { + want := make(map[string]bool, len(wantCodes)) + for _, code := range wantCodes { + want[code] = true + } + found := map[string]bool{} + cursor := "" + seenCursors := map[string]bool{} + for page := 0; page < devAppMaxReadbackPages; page++ { + params := map[string]any{"unifiedAppId": appID, "pageSize": 100} + if cursor != "" { + params["cursor"] = cursor + } + raw, err := rt.CallMCPData(productDevApp, "list_dev_app_events", params) + if err != nil { + return err + } + events, err := eventListProject(raw) + if err != nil { + return err + } + for _, event := range events { + code := devAppFirstString(event, "eventCode") + if want[code] { + found[code] = true + } + } + if len(found) == len(want) { + return nil + } + projection, err := devAppListProjection(raw, "events", events, "devapp/list_dev_app_events") + if err != nil { + return err + } + more, _ := projection["hasMore"].(bool) + if !more { + return devAppResponseError("devapp/list_dev_app_events", "subscription_readback_missing", "事件订阅终态读回缺少请求的 eventCode") + } + next := devAppFirstString(projection, "nextCursor") + if next == "" || next == cursor || seenCursors[next] { + return devAppResponseError("devapp/list_dev_app_events", "cursor_stall", "事件订阅读回游标未推进") + } + seenCursors[next] = true + cursor = next + } + return devAppResponseError("devapp/list_dev_app_events", "readback_page_limit", "事件订阅读回超过有界页数,无法证明全部事件已订阅") +} + // EventUnsubscribe maps helper `unsubscribe_dev_app_events`. var EventUnsubscribe = shortcut.Shortcut{ Service: "devapp", @@ -1336,20 +2139,72 @@ var VersionCreate = shortcut.Shortcut{ Description: "基于当前配置创建应用新版本", Intent: "当你改完应用配置、准备走发布流程前需要先打一个版本快照时使用;传入 unifiedAppId(可选显式版本号与描述,默认服务端自动递增),会实际创建一个新版本并返回 versionId 供后续预检和发布。", Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "devapp", Name: "shortcut_version_create", + CanonicalPath: "devapp.shortcut_version_create", CLIPath: "devapp +version-create", PrimaryCLIPath: "devapp +version-create", + }, + Description: "创建不可变版本快照并按应用与版本双 ID 精确读回", + Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "Reviewed adapter requires terminal success and a non-empty versionId, then reads get_dev_app_version_detail with the same unifiedAppId/versionId and compares supplied version metadata before verified output."}, + Selection: contract.SelectionSpec{ + AgentSummary: "基于当前配置创建应用新版本", + UseWhen: []string{"当你改完应用配置、准备走发布流程前需要先打一个版本快照时使用;传入 unifiedAppId(可选显式版本号与描述,默认服务端自动递增),会实际创建一个新版本并返回 versionId 供后续预检和发布。"}, + AvoidWhen: []string{"只需查看历史版本使用 +version-list;正式发布使用前先运行 +version-check-approval,发布能力当前仍不可用"}, + Examples: []string{"dws devapp +version-create --unified-app-id --desc "}, + }, + Result: devAppVerifiedMutationResult(), + }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, {Name: "version", Type: shortcut.FlagString, Desc: "高级可选:显式版本号,如 1.0.1;默认由服务端自动递增"}, {Name: "desc", Type: shortcut.FlagString, Desc: "版本描述"}, }, Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")} + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} if rt.Changed("version") { params["version"] = rt.Str("version") } if rt.Changed("desc") { params["desc"] = rt.Str("desc") } - return rt.CallMCP("create_dev_app_version", params) + if previewed, err := devAppWritePreview(rt, "create_dev_app_version", params); previewed || err != nil { + return err + } + created, err := callDevAppWrite(rt, "create_dev_app_version", params) + if err != nil { + return err + } + createdObject, err := requireDevAppObject(created, "devapp/create_dev_app_version") + if err != nil { + return err + } + versionID := devAppFirstString(createdObject, "versionId", "version_id", "id") + if versionID == "" { + return devAppResponseError("devapp/create_dev_app_version", "missing_version_id", "创建版本回执缺少稳定 versionId") + } + _, resource, err := readDevAppObject(rt, "get_dev_app_version_detail", map[string]any{ + "unifiedAppId": appID, + "versionId": versionID, + }, map[string]string{"unifiedAppId": appID, "versionId": versionID}) + if err != nil { + return err + } + if rt.Changed("version") { + if err := requireDevAppStringField(resource, "devapp/get_dev_app_version_detail", rt.Str("version"), "version", "versionName"); err != nil { + return err + } + } + if rt.Changed("desc") { + if err := requireDevAppStringField(resource, "devapp/get_dev_app_version_detail", rt.Str("desc"), "desc", "description", "remark"); err != nil { + return err + } + } + return rt.Output(verifiedDevAppMutation("version_create", appID, resource)) }, } @@ -1398,61 +2253,76 @@ var VersionList = shortcut.Shortcut{ if err != nil { return err } - versions := versionListProject(data) - return rt.Output(devAppListProjection(data, "versions", versions)) + versions, err := versionListProject(data) + if err != nil { + return err + } + projection, err := devAppListProjection(data, "versions", versions, "devapp/list_dev_app_versions") + if err != nil { + return err + } + return rt.Output(projection) }, } -func devAppListProjection(data map[string]any, key string, items []map[string]any) map[string]any { +func devAppListProjection(data map[string]any, key string, items []map[string]any, operation string) (map[string]any, error) { + if _, err := requireDevAppSuccess(data, operation); err != nil { + return nil, err + } out := map[string]any{"count": len(items), key: items} for _, candidate := range devAppPaginationCandidates(data) { - _, hasMore := candidate["hasMore"] - _, hasCursor := candidate["nextCursor"] + rawHasMore, hasMore := candidate["hasMore"] + rawCursor, hasCursor := candidate["nextCursor"] if !hasMore && !hasCursor { continue } - if hasMore { - out["hasMore"] = candidate["hasMore"] + if !hasMore { + return nil, devAppResponseError(operation, "missing_has_more", "分页响应只有 nextCursor,缺少 hasMore 终止证据") } + more, ok := rawHasMore.(bool) + if !ok { + return nil, devAppResponseError(operation, "malformed_has_more", "分页响应 hasMore 不是布尔值") + } + out["hasMore"] = more if hasCursor { - out["nextCursor"] = candidate["nextCursor"] + cursor, ok := rawCursor.(string) + if !ok { + return nil, devAppResponseError(operation, "malformed_cursor", "分页响应 nextCursor 不是字符串") + } + if strings.TrimSpace(cursor) != "" { + out["nextCursor"] = strings.TrimSpace(cursor) + } } - break + if more && devAppFirstString(out, "nextCursor") == "" { + return nil, devAppResponseError(operation, "missing_cursor", "分页响应 hasMore=true 但缺少可推进的 nextCursor") + } + return out, nil } - return out + return nil, devAppResponseError(operation, "missing_pagination", "分页响应缺少 hasMore 终止证据") } func devAppPaginationCandidates(data map[string]any) []map[string]any { - if data == nil { - return nil - } - candidates := []map[string]any{data} - for _, key := range []string{"content", "result", "data"} { - if nested, ok := data[key].(map[string]any); ok { - candidates = append(candidates, nested) - for _, innerKey := range []string{"result", "data"} { - if inner, ok := nested[innerKey].(map[string]any); ok { - candidates = append(candidates, inner) - } - } - } - } - return candidates + // Pagination evidence can occur at any of the reviewed envelope layers. + // Reuse the same bounded breadth-first walk as response validation so a + // valid content.result (or deeper content/result/data combination) cannot + // be mistaken for a response without a pagination terminus. + return devAppContainers(data) } // versionListProject reshapes list_dev_app_versions into a clean version list // ({versionId, version, status, desc, gmtCreate}) — output-projection fidelity // for clean output. The list container and per-item field names are probed defensively -// across candidate keys, so an unknown/empty shape yields an empty list rather -// than a crash or fabricated data. -func versionListProject(data map[string]any) []map[string]any { - raw := versionListFindList(data) +// across reviewed candidate keys, while missing, mistyped, or malformed +// collections fail closed. +func versionListProject(data map[string]any) ([]map[string]any, error) { + raw, _, err := requireDevAppCollection(data, "devapp/list_dev_app_versions", + []string{"versionId", "version_id", "id"}, "list", "items", "versions", "versionList") + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } + m := item.(map[string]any) row := map[string]any{} if v, ok := versionListFirst(m, "versionId", "version_id", "id"); ok { row["versionId"] = v @@ -1469,36 +2339,9 @@ func versionListProject(data map[string]any) []map[string]any { if v, ok := versionListFirst(m, "gmtCreate", "gmt_create", "createTime", "create_time"); ok { row["gmtCreate"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out -} - -// versionListFindList locates the version list payload, tolerating a bare -// top-level array or nesting one level under a common envelope key. -func versionListFindList(data map[string]any) []any { - if data == nil { - return []any{} - } - for _, k := range []string{"list", "items", "versions", "versionList", "result", "data"} { - v, ok := data[k] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "versions", "versionList", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} + return out, nil } // versionListFirst returns the first present candidate key's value. @@ -1553,11 +2396,15 @@ var VersionGet = shortcut.Shortcut{ {Name: "version-id", Type: shortcut.FlagString, Desc: "版本 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { + appID := rt.Str("unified-app-id") + versionID := rt.Str("version-id") params := map[string]any{ - "unifiedAppId": rt.Str("unified-app-id"), - "versionId": rt.Str("version-id"), + "unifiedAppId": appID, + "versionId": versionID, } - return rt.CallMCP("get_dev_app_version_detail", params) + return outputDevAppObject(rt, "get_dev_app_version_detail", params, map[string]string{ + "unifiedAppId": appID, "versionId": versionID, + }) }, } @@ -1605,12 +2452,16 @@ var VersionCheckApproval = shortcut.Shortcut{ {Name: "version-id", Type: shortcut.FlagString, Desc: "版本 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { + appID := rt.Str("unified-app-id") + versionID := rt.Str("version-id") params := map[string]any{ - "unifiedAppId": rt.Str("unified-app-id"), - "versionId": rt.Str("version-id"), + "unifiedAppId": appID, + "versionId": versionID, "precheckOnly": true, } - return rt.CallMCP("publish_dev_app_version", params) + return outputDevAppObject(rt, "publish_dev_app_version", params, map[string]string{ + "unifiedAppId": appID, "versionId": versionID, + }) }, } @@ -1688,50 +2539,87 @@ var VersionStatus = shortcut.Shortcut{ {Name: "version-id", Type: shortcut.FlagString, Desc: "版本 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { + appID := rt.Str("unified-app-id") + versionID := rt.Str("version-id") params := map[string]any{ - "unifiedAppId": rt.Str("unified-app-id"), - "versionId": rt.Str("version-id"), + "unifiedAppId": appID, + "versionId": versionID, } - return rt.CallMCP("get_dev_app_version_status", params) + return outputDevAppObject(rt, "get_dev_app_version_status", params, map[string]string{ + "unifiedAppId": appID, "versionId": versionID, + }) }, } +var devAppUnavailableReasons = map[string]string{ + "+permission-add": "权限申请缺少逐项终态回执与按 scopeValue 精确读回验证", + "+permission-remove": "权限移除缺少逐项终态回执与按 scopeValue 精确读回验证", + "+security-config": "整组覆盖写入缺少读取当前值、精确写回和恢复原配置的安全闭环", + "+event-unsubscribe": "事件退订缺少可恢复的稳定订阅 fixture 与逐项读回", + "+version-publish": "版本发布可能进入审批或线上生效,缺少安全测试租户与回滚合同", +} + +func unavailableDevAppShortcut(item shortcut.Shortcut, reason string) shortcut.Shortcut { + item.Hidden = true + item.Availability = shortcut.AvailabilityUnavailable + item.OutputRollout = output.RolloutDualValidate + item.Contract = corecmd.ContractDecl{} + constraintFlags := make([]string, 0, len(item.Flags)) + for _, flag := range item.Flags { + constraintFlags = append(constraintFlags, flag.Name) + } + item.Constraints = append(item.Constraints, shortcut.Constraint{ + Kind: shortcut.ConstraintCustom, + Flags: constraintFlags, + Description: "该命令保持 unavailable,直到文档化的安全验证前置条件满足", + }) + unavailable := func(*shortcut.RuntimeContext) error { + return apperrors.NewValidation("该 DevApp Shortcut 当前 unavailable:" + reason) + } + item.Validate = unavailable + item.Execute = unavailable + return item +} + func init() { - shortcut.Register( + items := []shortcut.Shortcut{ frameworkUnified(ListApp), frameworkUnified(GetApp), - frameworkDualValidate(CreateApp), - frameworkDualValidate(UpdateApp), - frameworkDualValidate(DeleteApp), - frameworkDualValidate(EnableApp), - frameworkDualValidate(DisableApp), - // Credentials contain secrets and still lack a reviewed public result - // projection. Keep legacy bytes while shadow-validating until Safety, - // Schema identity, and sensitive-path redaction are declared together. - frameworkDualValidate(GetCredentials), + frameworkUnified(CreateApp), + frameworkUnified(UpdateApp), + frameworkUnified(DeleteApp), + frameworkUnified(EnableApp), + frameworkUnified(DisableApp), + frameworkUnified(GetCredentials), frameworkUnified(WebappGet), - frameworkDualValidate(WebappConfig), + frameworkUnified(WebappConfig), frameworkUnified(PermissionList), frameworkDualValidate(PermissionAdd), frameworkDualValidate(PermissionRemove), - frameworkDualValidate(MemberList), - frameworkDualValidate(MemberAdd), - frameworkDualValidate(MemberRemove), + frameworkUnified(MemberList), + frameworkUnified(MemberAdd), + frameworkUnified(MemberRemove), frameworkDualValidate(SecurityConfig), frameworkUnified(RobotGet), - frameworkDualValidate(RobotConfig), - frameworkDualValidate(RobotEnable), - frameworkDualValidate(RobotDisable), + frameworkUnified(RobotConfig), + frameworkUnified(RobotEnable), + frameworkUnified(RobotDisable), frameworkUnified(EventList), - frameworkDualValidate(EventSubscribe), + frameworkUnified(EventSubscribe), frameworkDualValidate(EventUnsubscribe), - frameworkDualValidate(VersionCreate), + frameworkUnified(VersionCreate), frameworkUnified(VersionList), frameworkUnified(VersionGet), frameworkUnified(VersionCheckApproval), frameworkDualValidate(VersionPublish), frameworkUnified(VersionStatus), - ) + } + for index := range items { + if reason, unavailable := devAppUnavailableReasons[items[index].Command]; unavailable { + items[index] = unavailableDevAppShortcut(items[index], reason) + } + } + shortcut.Register(items...) } func frameworkUnified(item shortcut.Shortcut) shortcut.Shortcut { diff --git a/internal/shortcut/devapp/devapp_changed_line_cross_platform_coverage_test.go b/internal/shortcut/devapp/devapp_changed_line_cross_platform_coverage_test.go new file mode 100644 index 00000000..62de309e --- /dev/null +++ b/internal/shortcut/devapp/devapp_changed_line_cross_platform_coverage_test.go @@ -0,0 +1,742 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package devapp + +import ( + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/spf13/cobra" +) + +func devAppSuccessResult(values map[string]any) map[string]any { + return map[string]any{"success": true, "result": values} +} + +func devAppSuccessList(key string, items []any, more bool, cursor string) map[string]any { + result := map[string]any{"success": true, key: items, "hasMore": more} + if cursor != "" { + result["nextCursor"] = cursor + } + return result +} + +func TestCrossPlatformCoverageDevAppChangedStrictHelpers(t *testing.T) { + if devAppContainers(nil) != nil { + t.Fatal("nil response unexpectedly produced containers") + } + for _, tc := range []struct { + value any + want bool + }{ + {nil, false}, {false, false}, {true, true}, + {float64(0), false}, {float64(200), false}, {float64(500), true}, + {"", false}, {"ok", false}, {"SUCCESS", false}, {"denied", true}, + {map[string]any{}, false}, {map[string]any{"code": 1}, true}, + {[]any{}, false}, {[]any{"error"}, true}, {struct{}{}, true}, + } { + if got := devAppErrorValue(tc.value); got != tc.want { + t.Fatalf("devAppErrorValue(%#v)=%v, want %v", tc.value, got, tc.want) + } + } + + if _, err := requireDevAppSuccess(map[string]any{ + "success": true, "result": map[string]any{"versionStatus": "FAILED"}, + }, "devapp/test"); err != nil { + t.Fatalf("successful query with failed business state was rejected: %v", err) + } + if !devAppOnlyEnvelopeFields(map[string]any{"success": true, "result": map[string]any{}}) { + t.Fatal("known envelope was not recognized") + } + if devAppOnlyEnvelopeFields(map[string]any{"success": true, "business": true}) { + t.Fatal("business field was treated as envelope-only") + } + if _, err := requireDevAppObject(map[string]any{"success": true, "result": map[string]any{}}, "devapp/test"); err == nil { + t.Fatal("empty business object was accepted") + } + + object := map[string]any{"id": "actual", "name": "actual"} + if err := requireDevAppIdentity(object, "devapp/test", map[string]string{"ignored": ""}); err != nil { + t.Fatal(err) + } + if err := requireDevAppIdentity(object, "devapp/test", map[string]string{"missing": "wanted"}); err == nil { + t.Fatal("missing identity was accepted") + } + if err := requireDevAppIdentity(object, "devapp/test", map[string]string{"id": "wanted"}); err == nil { + t.Fatal("mismatched identity was accepted") + } + if err := requireDevAppFields(object, "devapp/test", map[string]any{"missing": "wanted"}); err == nil { + t.Fatal("missing readback field was accepted") + } + if err := requireDevAppFields(object, "devapp/test", map[string]any{"name": "wanted"}); err == nil { + t.Fatal("mismatched readback field was accepted") + } + if err := requireDevAppFields(object, "devapp/test", map[string]any{"name": "actual"}); err != nil { + t.Fatal(err) + } + if err := requireDevAppStringField(object, "devapp/test", "wanted", "missing"); err == nil { + t.Fatal("missing string field was accepted") + } + + deep := map[string]any{"success": true, "content": map[string]any{ + "result": map[string]any{"hasMore": true, "nextCursor": " next "}, + }} + projection, err := devAppListProjection(deep, "items", nil, "devapp/test") + if err != nil || projection["nextCursor"] != "next" { + t.Fatalf("deep pagination projection=%#v err=%v", projection, err) + } + if _, err := devAppListProjection(map[string]any{"success": false}, "items", nil, "devapp/test"); err == nil { + t.Fatal("failed list envelope was accepted") + } +} + +func TestCrossPlatformCoverageDevAppChangedReadAndListExecutors(t *testing.T) { + t.Run("list all filters and projection", func(t *testing.T) { + item := map[string]any{ + "unified_app_id": "app", "appName": "name", "clientId": "client", + "agent_id": "agent", "app_status": "normal", "modified_time": "now", + } + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "list_dev_app": {devAppSuccessList("apps", []any{item}, true, "next")}, + }} + err := runDevAppCoverage(t, ListApp, caller, + "--name", "name", "--app-key", "client", "--app-group-id", "7", + "--creator", "owner", "--robot-name", "robot", "--develop-type", "2", + "--filter-cool-app", "1", "--sort-type", "gmt_modified", "--sort-order", "desc", + "--cursor", "cursor", "--page-size", "9") + if err != nil { + t.Fatal(err) + } + want := map[string]any{ + "name": "name", "appKey": "client", "appGroupId": 7, "creator": "owner", + "robotName": "robot", "developType": 2, "filterCoolApp": 1, + "sortType": "gmt_modified", "sortOrder": "desc", "cursor": "cursor", "pageSize": 9, + } + if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].params, want) { + t.Fatalf("list params=%#v, want %#v", caller.calls, want) + } + badPage := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "list_dev_app": {{"success": true, "list": []any{map[string]any{"unifiedAppId": "app"}}}}, + }} + if err := runDevAppCoverage(t, ListApp, badPage); err == nil { + t.Fatal("list without terminal pagination evidence was accepted") + } + }) + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + tool string + args []string + result map[string]any + }{ + {"get", GetApp, "get_dev_app", []string{"--unified-app-id", "app"}, map[string]any{"unifiedAppId": "app", "name": "app"}}, + {"webapp", WebappGet, "get_extension_webapp_config", []string{"--unified-app-id", "app"}, map[string]any{"unifiedAppId": "app", "homepageUrl": "https://example.invalid"}}, + {"robot", RobotGet, "get_extension_robot_config", []string{"--unified-app-id", "app"}, map[string]any{"unifiedAppId": "app", "robotStatus": "ONLINE"}}, + {"version", VersionGet, "get_dev_app_version_detail", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version"}}, + {"precheck", VersionCheckApproval, "publish_dev_app_version", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "approvalRequired": false}}, + {"status", VersionStatus, "get_dev_app_version_status", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "status": "PUBLISHED"}}, + {"status failed", VersionStatus, "get_dev_app_version_status", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "versionStatus": "FAILED"}}, + {"status expired", VersionStatus, "get_dev_app_version_status", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "versionStatus": "EXPIRED"}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{tc.tool: {devAppSuccessResult(tc.result)}}} + if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err != nil { + t.Fatal(err) + } + }) + } + + t.Run("read transport and identity failure", func(t *testing.T) { + if err := runDevAppCoverage(t, GetApp, &devAppCoverageCaller{}, "--unified-app-id", "app"); err == nil { + t.Fatal("transport failure was accepted") + } + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "other"})}, + }} + if err := runDevAppCoverage(t, GetApp, caller, "--unified-app-id", "app"); err == nil { + t.Fatal("wrong object identity was accepted") + } + }) + + t.Run("permission list", func(t *testing.T) { + item := map[string]any{ + "permissionCode": "scope", "permissionName": "name", "interfaceName": "api", + "auth_status": "AUTHED", "scope_type": "APP", + } + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "list_dev_app_permissions": {devAppSuccessList("permissions", []any{item}, false, "")}, + }} + err := runDevAppCoverage(t, PermissionList, caller, + "--unified-app-id", "app", "--keyword", "word", "--scope-value", "scope", + "--auth-status", "authed", "--scope-type", "app", "--api-status", "OPEN") + if err != nil { + t.Fatal(err) + } + if _, ok := permissionListFirst(item, "missing"); ok { + t.Fatal("missing permission field unexpectedly resolved") + } + }) + + t.Run("member list success and failures", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "user"}}, false, "")}, + }} + if err := runDevAppCoverage(t, MemberList, caller, "--unified-app-id", "app", "--user-id", " user "); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, MemberList, &devAppCoverageCaller{}, "--unified-app-id", "app"); err == nil { + t.Fatal("member transport failure was accepted") + } + bad := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "list_dev_app_members": {{"success": true, "members": "bad"}}, + }} + if err := runDevAppCoverage(t, MemberList, bad, "--unified-app-id", "app"); err == nil { + t.Fatal("malformed member list was accepted") + } + }) + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + tool string + args []string + key string + item map[string]any + }{ + {"events", EventList, "list_dev_app_events", []string{"--unified-app-id", "app", "--keyword", "event"}, "events", map[string]any{"event_code": "event", "event_name": "name", "subscribe_status": "ON", "modified_time": "now"}}, + {"versions", VersionList, "list_dev_app_versions", []string{"--unified-app-id", "app"}, "versions", map[string]any{"version_id": "version", "version_name": "1", "versionStatus": "READY", "remark": "desc", "create_time": "now"}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + tc.tool: {devAppSuccessList(tc.key, []any{tc.item}, false, "")}, + }} + if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err != nil { + t.Fatal(err) + } + }) + } + if _, ok := eventListFirst(map[string]any{}, "missing"); ok { + t.Fatal("missing event field unexpectedly resolved") + } + if _, ok := versionListFirst(map[string]any{}, "missing"); ok { + t.Fatal("missing version field unexpectedly resolved") + } +} + +func TestCrossPlatformCoverageDevAppChangedWriteExecutors(t *testing.T) { + t.Run("create all fields and failure branches", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})}, + "get_dev_app": {devAppSuccessResult(map[string]any{ + "unifiedAppId": "app", "name": "created", "desc": "desc", "iconMediaId": "icon", + })}, + }} + if err := runDevAppCoverage(t, CreateApp, caller, + "--name", "created", "--desc", "desc", "--icon-media-id", "icon", "--yes"); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, CreateApp, &devAppCoverageCaller{}, "--name", "created", "--dry-run"); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, CreateApp, &devAppCoverageCaller{}, "--name", "created", "--yes"); err == nil { + t.Fatal("create transport failure was accepted") + } + for name, responses := range map[string]map[string][]map[string]any{ + "receipt object missing": {"create_dev_app": {{"success": true, "result": map[string]any{}}}}, + "resource id missing": {"create_dev_app": {devAppSuccessResult(map[string]any{"name": "created"})}}, + "readback failure": { + "create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})}, + }, + "name mismatch": { + "create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})}, + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "other"})}, + }, + "description mismatch": { + "create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})}, + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "created", "desc": "other"})}, + }, + "icon mismatch": { + "create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})}, + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "created", "iconMediaId": "other"})}, + }, + } { + t.Run(name, func(t *testing.T) { + args := []string{"--name", "created", "--yes"} + if strings.Contains(name, "description") { + args = append(args, "--desc", "desc") + } + if strings.Contains(name, "icon") { + args = append(args, "--icon-media-id", "icon") + } + if err := runDevAppCoverage(t, CreateApp, &devAppCoverageCaller{responses: responses}, args...); err == nil { + t.Fatal("invalid create sequence was accepted") + } + }) + } + }) + + t.Run("update all fields and dry run", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "update_dev_app": {map[string]any{"success": true}}, + "get_dev_app": {devAppSuccessResult(map[string]any{ + "unifiedAppId": "app", "name": "new", "desc": "desc", "iconMediaId": "icon", + })}, + }} + if err := runDevAppCoverage(t, UpdateApp, caller, + "--unified-app-id", "app", "--name", "new", "--desc", "desc", "--icon-media-id", "icon", "--yes"); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, UpdateApp, &devAppCoverageCaller{}, + "--unified-app-id", "app", "--name", "new", "--dry-run"); err != nil { + t.Fatal(err) + } + bad := &devAppCoverageCaller{responses: map[string][]map[string]any{"update_dev_app": {map[string]any{"success": true}}}} + if err := runDevAppCoverage(t, UpdateApp, bad, "--unified-app-id", "app", "--name", "new", "--yes"); err == nil { + t.Fatal("update readback failure was accepted") + } + }) + + t.Run("delete guards and bounded readback", func(t *testing.T) { + if err := runDevAppCoverage(t, DeleteApp, &devAppCoverageCaller{}, "--unified-app-id", "app", "--dry-run"); err != nil { + t.Fatal(err) + } + if err := verifyDeletedDevApp(shortcut.RuntimeContextForTest(&cobra.Command{Use: "+delete"}, DeleteApp), "app", ""); err == nil { + t.Fatal("delete verification without selector was accepted") + } + missingSelector := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})}, + }} + if err := runDevAppCoverage(t, DeleteApp, missingSelector, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("delete without appKey was accepted") + } + writeFailure := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})}, + }} + if err := runDevAppCoverage(t, DeleteApp, writeFailure, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("delete transport failure was accepted") + } + stillPresent := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})}, + "delete_dev_app": {map[string]any{"success": true}}, + "list_dev_app": {devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "app"}}, false, "")}, + }} + if err := runDevAppCoverage(t, DeleteApp, stillPresent, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("delete with present readback was accepted") + } + stalled := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})}, + "delete_dev_app": {map[string]any{"success": true}}, + "list_dev_app": { + devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "other"}}, true, "same"), + devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "other"}}, true, "same"), + }, + }} + if err := runDevAppCoverage(t, DeleteApp, stalled, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("delete stalled pagination was accepted") + } + malformed := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})}, + "delete_dev_app": {map[string]any{"success": true}}, + "list_dev_app": {{"success": true, "list": "bad", "hasMore": false}}, + }} + if err := runDevAppCoverage(t, DeleteApp, malformed, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("delete malformed readback was accepted") + } + badPage := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})}, + "delete_dev_app": {map[string]any{"success": true}}, + "list_dev_app": {{"success": true, "list": []any{map[string]any{"unifiedAppId": "other"}}}}, + }} + if err := runDevAppCoverage(t, DeleteApp, badPage, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("delete pagination without termination was accepted") + } + pages := make([]map[string]any, 0, devAppMaxReadbackPages) + for page := 0; page < devAppMaxReadbackPages; page++ { + pages = append(pages, devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "other"}}, true, "cursor-"+string(rune('a'+page)))) + } + pageLimitCaller := &devAppCoverageCaller{responses: map[string][]map[string]any{"list_dev_app": pages}} + helpers.InitDeps(pageLimitCaller) + if err := verifyDeletedDevApp(shortcut.RuntimeContextForTest(&cobra.Command{Use: "+delete"}, DeleteApp), "app", "key"); err == nil { + t.Fatal("delete verification page limit was accepted") + } + helpers.InitDeps(&devAppCoverageCaller{}) + if err := verifyDeletedDevApp(shortcut.RuntimeContextForTest(&cobra.Command{Use: "+delete"}, DeleteApp), "app", "key"); err == nil { + t.Fatal("delete verification transport failure was accepted") + } + }) + + t.Run("credentials require client id", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app_credentials": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appSecret": "secret"})}, + }} + if err := runDevAppCoverage(t, GetCredentials, caller, "--unified-app-id", "app"); err == nil { + t.Fatal("credential without client id was accepted") + } + }) + + t.Run("webapp all fields and failures", func(t *testing.T) { + args := []string{"--unified-app-id", "app", "--h5-page-type", "H5", "--homepage-url", "https://example.invalid/h5", "--pc-homepage-url", "https://example.invalid/pc", "--omp-url", "https://example.invalid/omp", "--yes"} + resource := map[string]any{"unifiedAppId": "app", "h5PageType": "H5", "homepageUrl": "https://example.invalid/h5", "pcHomepageUrl": "https://example.invalid/pc", "ompUrl": "https://example.invalid/omp"} + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_webapp_config": {map[string]any{"success": true}}, + "get_extension_webapp_config": {devAppSuccessResult(resource)}, + }} + if err := runDevAppCoverage(t, WebappConfig, caller, args...); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, WebappConfig, &devAppCoverageCaller{}, "--unified-app-id", "app", "--homepage-url", "https://example.invalid", "--dry-run"); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, WebappConfig, &devAppCoverageCaller{}, args...); err == nil { + t.Fatal("webapp write failure was accepted") + } + badRead := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_webapp_config": {map[string]any{"success": true}}, + }} + if err := runDevAppCoverage(t, WebappConfig, badRead, args...); err == nil { + t.Fatal("webapp readback failure was accepted") + } + mismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_webapp_config": {map[string]any{"success": true}}, + "get_extension_webapp_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "h5PageType": "other"})}, + }} + if err := runDevAppCoverage(t, WebappConfig, mismatch, "--unified-app-id", "app", "--h5-page-type", "H5", "--yes"); err == nil { + t.Fatal("webapp mismatch was accepted") + } + }) +} + +func TestCrossPlatformCoverageDevAppChangedRobotEventAndVersionBranches(t *testing.T) { + t.Run("robot validate and all fields", func(t *testing.T) { + for _, args := range [][]string{ + {"--unified-app-id", "app", "--skills", "skill", "--yes"}, + {"--unified-app-id", "app", "--add-scope=false", "--yes"}, + } { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_robot_config": {map[string]any{"success": true}}, + "get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "skills": []any{"skill"}, "addScope": false})}, + }} + if err := runDevAppCoverage(t, RobotConfig, caller, args...); err != nil { + t.Fatal(err) + } + } + args := []string{"--unified-app-id", "app", "--name", "bot", "--brief", "brief", "--desc", "desc", "--icon-media-id", "icon", "--outgoing-url", "https://example.invalid/out", "--event-callback-url", "https://example.invalid/event", "--mode", "STREAM", "--skills", "skill", "--add-scope=false", "--disable-ssl-verify=false", "--yes"} + resource := map[string]any{"unifiedAppId": "app", "name": "bot", "brief": "brief", "desc": "desc", "iconMediaId": "icon", "outgoingUrl": "https://example.invalid/out", "eventCallbackUrl": "https://example.invalid/event", "mode": "STREAM", "skills": []string{"skill"}, "addScope": false, "disableSSLVerify": false} + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_robot_config": {map[string]any{"success": true}}, + "get_extension_robot_config": {devAppSuccessResult(resource)}, + }} + if err := runDevAppCoverage(t, RobotConfig, caller, args...); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, RobotConfig, &devAppCoverageCaller{}, "--unified-app-id", "app", "--name", "bot", "--dry-run"); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, RobotConfig, &devAppCoverageCaller{}, args...); err == nil { + t.Fatal("robot write failure was accepted") + } + badRead := &devAppCoverageCaller{responses: map[string][]map[string]any{"set_extension_robot_config": {map[string]any{"success": true}}}} + if err := runDevAppCoverage(t, RobotConfig, badRead, args...); err == nil { + t.Fatal("robot readback failure was accepted") + } + mismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_robot_config": {map[string]any{"success": true}}, + "get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "other"})}, + }} + if err := runDevAppCoverage(t, RobotConfig, mismatch, "--unified-app-id", "app", "--name", "bot", "--yes"); err == nil { + t.Fatal("robot mismatch was accepted") + } + boolMismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_robot_config": {map[string]any{"success": true}}, + "get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "addScope": true})}, + }} + if err := runDevAppCoverage(t, RobotConfig, boolMismatch, "--unified-app-id", "app", "--add-scope=false", "--yes"); err == nil { + t.Fatal("robot boolean mismatch was accepted") + } + skillsMismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_robot_config": {map[string]any{"success": true}}, + "get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "skills": []string{"other"}})}, + }} + if err := runDevAppCoverage(t, RobotConfig, skillsMismatch, "--unified-app-id", "app", "--skills", "skill", "--yes"); err == nil { + t.Fatal("robot skills mismatch was accepted") + } + }) + + t.Run("status previews and readback failures", func(t *testing.T) { + if err := runDevAppCoverage(t, EnableApp, &devAppCoverageCaller{}, "--unified-app-id", "app", "--dry-run"); err != nil { + t.Fatal(err) + } + readFailure := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "enable_dev_app": {map[string]any{"success": true}}, + }} + if err := runDevAppCoverage(t, EnableApp, readFailure, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("app status readback failure was accepted") + } + mismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "enable_dev_app": {map[string]any{"success": true}}, + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appStatus": "disabled"})}, + }} + if err := runDevAppCoverage(t, EnableApp, mismatch, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("app status mismatch was accepted") + } + if err := runDevAppCoverage(t, RobotEnable, &devAppCoverageCaller{}, "--unified-app-id", "app", "--dry-run"); err != nil { + t.Fatal(err) + } + robotReadFailure := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "enable_dev_app_robot": {map[string]any{"success": true}}, + }} + if err := runDevAppCoverage(t, RobotEnable, robotReadFailure, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("robot status readback failure was accepted") + } + }) + + t.Run("member mutations validate receipts and exact readback", func(t *testing.T) { + member := map[string]any{"userId": "user", "memberType": "DEVELOPER"} + add := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "add_dev_app_members": {{"success": true}}, + "list_dev_app_members": {devAppSuccessList("members", []any{member}, false, "")}, + }} + if err := runDevAppCoverage(t, MemberAdd, add, + "--unified-app-id", "app", "--user-ids", " user ", "--member-type", " DEVELOPER ", "--yes"); err != nil { + t.Fatal(err) + } + if len(add.calls) != 2 || add.calls[0].tool != "add_dev_app_members" || + !reflect.DeepEqual(add.calls[0].params["userIds"], []string{"user"}) || + add.calls[0].params["memberType"] != "DEVELOPER" || add.calls[1].tool != "list_dev_app_members" { + t.Fatalf("member add calls=%#v", add.calls) + } + + remove := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "remove_dev_app_members": {{"success": true}}, + "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "owner", "memberType": "OWNER"}}, false, "")}, + }} + if err := runDevAppCoverage(t, MemberRemove, remove, + "--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER", "--yes"); err != nil { + t.Fatal(err) + } + if len(remove.calls) != 2 || remove.calls[0].tool != "remove_dev_app_members" || remove.calls[1].tool != "list_dev_app_members" { + t.Fatalf("member remove calls=%#v", remove.calls) + } + + for _, declaration := range []shortcut.Shortcut{MemberAdd, MemberRemove} { + unconfirmed := &devAppCoverageCaller{} + if err := runDevAppCoverage(t, declaration, unconfirmed, + "--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER"); err == nil { + t.Fatalf("%s succeeded without confirmation", declaration.Command) + } + if len(unconfirmed.calls) != 0 { + t.Fatalf("%s made remote calls before confirmation: %#v", declaration.Command, unconfirmed.calls) + } + dryRun := &devAppCoverageCaller{} + if err := runDevAppCoverage(t, declaration, dryRun, + "--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER", "--dry-run"); err != nil { + t.Fatal(err) + } + if len(dryRun.calls) != 0 { + t.Fatalf("%s dry-run made remote calls: %#v", declaration.Command, dryRun.calls) + } + } + }) + + t.Run("member mutations reject invalid input and false success", func(t *testing.T) { + for _, values := range [][]string{nil, {""}, {"user", "user"}} { + if _, err := validatedDevAppValues(values, "--user-ids"); err == nil { + t.Fatalf("invalid member IDs accepted: %#v", values) + } + } + if _, err := validatedDevAppMemberType(" "); err == nil { + t.Fatal("blank member type was accepted") + } + if got, err := validatedDevAppMemberType(" DEVELOPER "); err != nil || got != "DEVELOPER" { + t.Fatalf("normalized member type=%q err=%v", got, err) + } + for _, args := range [][]string{ + {"--unified-app-id", "app", "--user-ids", "user,user", "--member-type", "DEVELOPER", "--yes"}, + {"--unified-app-id", "app", "--user-ids", "user", "--member-type", " ", "--yes"}, + } { + caller := &devAppCoverageCaller{} + if err := runDevAppCoverage(t, MemberAdd, caller, args...); err == nil { + t.Fatalf("invalid member arguments accepted: %#v", args) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid member arguments made calls: %#v", caller.calls) + } + } + + baseArgs := []string{"--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER", "--yes"} + badAddResponses := []map[string][]map[string]any{ + {}, + {"add_dev_app_members": {{"success": false}}}, + {"add_dev_app_members": {{"success": true}}}, + {"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {{"success": true, "members": "bad"}}}, + {"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "other", "memberType": "DEVELOPER"}}, false, "")}}, + {"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "user"}}, false, "")}}, + {"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "user", "memberType": "OWNER"}}, false, "")}}, + {"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{ + map[string]any{"userId": "user", "memberType": "DEVELOPER"}, + map[string]any{"userId": "user", "memberType": "DEVELOPER"}, + }, false, "")}}, + } + for index, responses := range badAddResponses { + if err := runDevAppCoverage(t, MemberAdd, &devAppCoverageCaller{responses: responses}, baseArgs...); err == nil { + t.Fatalf("member add false success case %d was accepted", index) + } + } + + removeStillPresent := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "remove_dev_app_members": {{"success": true}}, + "list_dev_app_members": {devAppSuccessList("members", []any{ + map[string]any{"userId": "user", "memberType": "DEVELOPER"}, + }, false, "")}, + }} + if err := runDevAppCoverage(t, MemberRemove, removeStillPresent, baseArgs...); err == nil { + t.Fatal("member remove accepted target still present") + } + }) + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + writeTool string + status any + }{ + {"enable missing status", RobotEnable, "enable_dev_app_robot", nil}, + {"disable wrong status", RobotDisable, "disable_dev_app_robot", "ONLINE"}, + } { + t.Run(tc.name, func(t *testing.T) { + resource := map[string]any{"unifiedAppId": "app"} + if tc.status != nil { + resource["robotStatus"] = tc.status + } + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + tc.writeTool: {map[string]any{"success": true}}, + "get_extension_robot_config": {devAppSuccessResult(resource)}, + }} + if err := runDevAppCoverage(t, tc.declaration, caller, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("invalid robot status was accepted") + } + }) + } + + t.Run("event validation and readback failures", func(t *testing.T) { + for _, values := range [][]string{nil, {""}, {"event", "event"}} { + if _, err := validatedDevAppValues(values, "--event-codes"); err == nil { + t.Fatalf("invalid event values accepted: %#v", values) + } + } + if got, err := validatedDevAppValues([]string{" event "}, "--event-codes"); err != nil || !reflect.DeepEqual(got, []string{"event"}) { + t.Fatalf("normalized event values=%#v err=%v", got, err) + } + if err := runDevAppCoverage(t, EventSubscribe, &devAppCoverageCaller{}, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil { + t.Fatal("event write failure was accepted") + } + if err := runDevAppCoverage(t, EventSubscribe, &devAppCoverageCaller{}, "--unified-app-id", "app", "--event-codes", "event", "--dry-run"); err != nil { + t.Fatal(err) + } + for name, listResponses := range map[string][]map[string]any{ + "transport": nil, + "malformed": {{"success": true, "list": "bad", "hasMore": false}}, + "projection": {devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "")}, + "stalled": {devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "same")}, + } { + t.Run(name, func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "subscribe_dev_app_events": {map[string]any{"success": true}}, + "list_dev_app_events": listResponses, + }} + if err := runDevAppCoverage(t, EventSubscribe, caller, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil { + t.Fatal("invalid event readback was accepted") + } + }) + } + stalled := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "subscribe_dev_app_events": {map[string]any{"success": true}}, + "list_dev_app_events": { + devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "same"), + devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "same"), + }, + }} + if err := runDevAppCoverage(t, EventSubscribe, stalled, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil { + t.Fatal("stalled event cursor was accepted") + } + pages := make([]map[string]any, 0, devAppMaxReadbackPages) + for page := 0; page < devAppMaxReadbackPages; page++ { + pages = append(pages, devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "cursor-"+string(rune('a'+page)))) + } + pageLimit := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "subscribe_dev_app_events": {map[string]any{"success": true}}, + "list_dev_app_events": pages, + }} + if err := runDevAppCoverage(t, EventSubscribe, pageLimit, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil { + t.Fatal("event readback page limit was accepted") + } + }) + + t.Run("version create all fields and failures", func(t *testing.T) { + args := []string{"--unified-app-id", "app", "--version", "1.0", "--desc", "desc", "--yes"} + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})}, + "get_dev_app_version_detail": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "versionId": "version", "version": "1.0", "desc": "desc"})}, + }} + if err := runDevAppCoverage(t, VersionCreate, caller, args...); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, VersionCreate, &devAppCoverageCaller{}, "--unified-app-id", "app", "--desc", "desc", "--dry-run"); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, VersionCreate, &devAppCoverageCaller{}, args...); err == nil { + t.Fatal("version write failure was accepted") + } + for name, responses := range map[string]map[string][]map[string]any{ + "receipt object": {"create_dev_app_version": {{"success": true, "result": map[string]any{}}}}, + "missing id": {"create_dev_app_version": {devAppSuccessResult(map[string]any{"version": "1.0"})}}, + "readback": {"create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})}}, + "version mismatch": { + "create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})}, + "get_dev_app_version_detail": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "versionId": "version", "version": "other"})}, + }, + "description mismatch": { + "create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})}, + "get_dev_app_version_detail": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "versionId": "version", "version": "1.0", "desc": "other"})}, + }, + } { + t.Run(name, func(t *testing.T) { + if err := runDevAppCoverage(t, VersionCreate, &devAppCoverageCaller{responses: responses}, args...); err == nil { + t.Fatal("invalid version create sequence was accepted") + } + }) + } + }) + + t.Run("list pagination projection failures", func(t *testing.T) { + for _, tc := range []struct { + declaration shortcut.Shortcut + tool string + args []string + key string + idKey string + }{ + {PermissionList, "list_dev_app_permissions", []string{"--unified-app-id", "app"}, "permissions", "scopeValue"}, + {EventList, "list_dev_app_events", []string{"--unified-app-id", "app"}, "events", "eventCode"}, + {VersionList, "list_dev_app_versions", []string{"--unified-app-id", "app"}, "versions", "versionId"}, + } { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + tc.tool: {{"success": true, tc.key: []any{map[string]any{tc.idKey: "id"}}}}, + }} + if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatalf("%s accepted missing pagination", tc.declaration.Command) + } + } + malformedEvent := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "list_dev_app_events": {{"success": true, "events": "bad", "hasMore": false}}, + }} + if err := runDevAppCoverage(t, EventList, malformedEvent, "--unified-app-id", "app"); err == nil { + t.Fatal("malformed event collection was accepted") + } + }) +} diff --git a/internal/shortcut/devapp/devapp_hardening_test.go b/internal/shortcut/devapp/devapp_hardening_test.go new file mode 100644 index 00000000..3a151f0e --- /dev/null +++ b/internal/shortcut/devapp/devapp_hardening_test.go @@ -0,0 +1,527 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package devapp + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type devAppCoverageCall struct { + tool string + params map[string]any +} + +type devAppCoverageCaller struct { + responses map[string][]map[string]any + calls []devAppCoverageCall +} + +func (c *devAppCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) { + c.calls = append(c.calls, devAppCoverageCall{tool: tool, params: params}) + queue := c.responses[tool] + if len(queue) == 0 { + return nil, errors.New("unexpected devapp coverage call: " + tool) + } + payload := queue[0] + c.responses[tool] = queue[1:] + encoded, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(encoded)}}}, nil +} + +func (*devAppCoverageCaller) Format() string { return "json" } +func (*devAppCoverageCaller) DryRun() bool { return false } +func (*devAppCoverageCaller) Fields() string { return "" } +func (*devAppCoverageCaller) JQ() string { return "" } + +func runDevAppCoverage(t *testing.T, declaration shortcut.Shortcut, caller *devAppCoverageCaller, args ...string) error { + t.Helper() + helpers.InitDeps(caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + service := &cobra.Command{Use: "devapp"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetIn(strings.NewReader("")) + root.SetArgs(append([]string{"devapp", declaration.Command}, args...)) + return root.Execute() +} + +func runDevAppUnifiedCoverage(t *testing.T, declaration shortcut.Shortcut, caller *devAppCoverageCaller, args ...string) ([]byte, error) { + t.Helper() + helpers.InitDeps(caller) + ctx, _ := output.WithResultStore(context.Background()) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.SetContext(ctx) + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + service := &cobra.Command{Use: "devapp"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(io.Discard) + root.SetIn(strings.NewReader("")) + root.PersistentPostRunE = func(executed *cobra.Command, _ []string) error { + _, _, err := output.EmitStoredResult(executed) + return err + } + root.SetArgs(append([]string{"devapp", declaration.Command}, args...)) + if err := root.Execute(); err != nil { + return nil, err + } + return stdout.Bytes(), nil +} + +func devAppRegistered(command string) (shortcut.Shortcut, bool) { + for _, item := range shortcut.All() { + if item.Service == "devapp" && item.Command == command { + return item, true + } + } + return shortcut.Shortcut{}, false +} + +func TestCrossPlatformCoverageDevAppStrictResponseValidation(t *testing.T) { + for _, tc := range []struct { + name string + data map[string]any + }{ + {name: "empty", data: nil}, + {name: "missing success", data: map[string]any{"list": []any{}}}, + {name: "wrong success type", data: map[string]any{"success": "true", "list": []any{}}}, + {name: "explicit failure", data: map[string]any{"success": false, "list": []any{}}}, + {name: "conflicting error", data: map[string]any{"success": true, "errorCode": "DENIED", "list": []any{}}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := listAppProject(tc.data); err == nil { + t.Fatal("malformed response was accepted") + } + }) + } + + for _, tc := range []struct { + name string + data map[string]any + }{ + {name: "missing collection", data: map[string]any{"success": true, "hasMore": false}}, + {name: "wrong collection type", data: map[string]any{"success": true, "list": map[string]any{}, "hasMore": false}}, + {name: "bad element", data: map[string]any{"success": true, "list": []any{"bad"}, "hasMore": false}}, + {name: "missing stable id", data: map[string]any{"success": true, "list": []any{map[string]any{"name": "app"}}, "hasMore": false}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := listAppProject(tc.data); err == nil { + t.Fatal("malformed collection was accepted") + } + }) + } + + valid := []map[string]any{{"unifiedAppId": "app"}} + for _, tc := range []struct { + name string + data map[string]any + }{ + {name: "missing pagination", data: map[string]any{"success": true}}, + {name: "wrong has more type", data: map[string]any{"success": true, "hasMore": "false"}}, + {name: "cursor without has more", data: map[string]any{"success": true, "nextCursor": "next"}}, + {name: "more without cursor", data: map[string]any{"success": true, "hasMore": true}}, + {name: "wrong cursor type", data: map[string]any{"success": true, "hasMore": true, "nextCursor": 1}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := devAppListProjection(tc.data, "apps", valid, "devapp/test"); err == nil { + t.Fatal("malformed pagination was accepted") + } + }) + } +} + +func TestCrossPlatformCoverageDevAppConfirmationAndValidationPrecedeCalls(t *testing.T) { + writes := []struct { + declaration shortcut.Shortcut + args []string + }{ + {CreateApp, []string{"--name", "app"}}, + {UpdateApp, []string{"--unified-app-id", "app", "--name", "new"}}, + {DeleteApp, []string{"--unified-app-id", "app"}}, + {EnableApp, []string{"--unified-app-id", "app"}}, + {DisableApp, []string{"--unified-app-id", "app"}}, + {WebappConfig, []string{"--unified-app-id", "app", "--homepage-url", "https://example.invalid"}}, + {RobotConfig, []string{"--unified-app-id", "app", "--name", "bot"}}, + {RobotEnable, []string{"--unified-app-id", "app"}}, + {RobotDisable, []string{"--unified-app-id", "app"}}, + {EventSubscribe, []string{"--unified-app-id", "app", "--event-codes", "event.one"}}, + {VersionCreate, []string{"--unified-app-id", "app", "--desc", "version"}}, + } + for _, tc := range writes { + t.Run(tc.declaration.Command, func(t *testing.T) { + caller := &devAppCoverageCaller{} + err := runDevAppCoverage(t, tc.declaration, caller, tc.args...) + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "confirmation_required" { + t.Fatalf("unconfirmed error = %#v, want confirmation_required", err) + } + if len(caller.calls) != 0 { + t.Fatalf("unconfirmed command called MCP: %#v", caller.calls) + } + }) + } + + for _, tc := range []struct { + declaration shortcut.Shortcut + args []string + }{ + {UpdateApp, []string{"--unified-app-id", "app", "--yes"}}, + {WebappConfig, []string{"--unified-app-id", "app", "--yes"}}, + {RobotConfig, []string{"--unified-app-id", "app", "--yes"}}, + {EventSubscribe, []string{"--unified-app-id", "app", "--event-codes", "event.one,event.one", "--yes"}}, + } { + caller := &devAppCoverageCaller{} + err := runDevAppCoverage(t, tc.declaration, caller, tc.args...) + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason == "confirmation_required" { + t.Fatalf("invalid input error = %#v", err) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid input called MCP: %#v", caller.calls) + } + } +} + +func TestCrossPlatformCoverageDevAppResidualAdapters(t *testing.T) { + t.Run("credentials require stable identity client and secret", func(t *testing.T) { + valid := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app_credentials": {{"success": true, "result": map[string]any{ + "unifiedAppId": "app", "appKey": "client", "appSecret": "secret", + }}}, + }} + if err := runDevAppCoverage(t, GetCredentials, valid, "--unified-app-id", "app"); err != nil { + t.Fatal(err) + } + missingSecret := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app_credentials": {{"success": true, "result": map[string]any{ + "unifiedAppId": "app", "appKey": "client", + }}}, + }} + if err := runDevAppCoverage(t, GetCredentials, missingSecret, "--unified-app-id", "app"); err == nil { + t.Fatal("credential response without a secret was accepted") + } + }) + + t.Run("credentials unified data matches declared sensitive paths", func(t *testing.T) { + declaration, ok := devAppRegistered("+credentials-get") + if !ok || declaration.OutputRollout != output.RolloutUnifiedActive { + t.Fatalf("registered credentials shortcut is not unified: found=%t rollout=%q", ok, declaration.OutputRollout) + } + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app_credentials": {{"success": true, "result": map[string]any{ + "unifiedAppId": "app", "appKey": "client", "appSecret": "test-app-secret", + "clientSecret": "test-client-secret", "secret": "test-secret", + }}}, + }} + encoded, err := runDevAppUnifiedCoverage(t, declaration, caller, "--unified-app-id", "app") + if err != nil { + t.Fatal(err) + } + var envelope map[string]any + if err := json.Unmarshal(encoded, &envelope); err != nil { + t.Fatalf("unified output is not JSON: %v", err) + } + data, ok := envelope["data"].(map[string]any) + if !ok { + t.Fatalf("unified output data type = %T, want object", envelope["data"]) + } + if _, nested := data["result"]; nested { + t.Fatal("credential business object is nested under data.result; declared sensitive paths would miss it") + } + for _, path := range declaration.Contract.Result.SensitivePaths { + if value, present := data[path]; !present || strings.TrimSpace(fmt.Sprint(value)) == "" { + t.Fatalf("declared sensitive path %q does not resolve to a non-empty data field", path) + } + } + }) + + t.Run("member projector supports known and guaranteed zero", func(t *testing.T) { + members := []any{ + map[string]any{"userId": "user-1", "memberType": "OWNER"}, + map[string]any{"userId": "user-2", "memberType": "DEVELOPER"}, + } + if got := projectDevAppMembers(members, "user-1"); len(got) != 1 || got[0]["userId"] != "user-1" { + t.Fatalf("known member projection = %#v", got) + } + if got := projectDevAppMembers(members, "guaranteed-missing"); len(got) != 0 { + t.Fatalf("zero member projection = %#v", got) + } + for _, bad := range []map[string]any{ + {"success": true, "members": []any{"bad"}}, + {"success": true, "members": []any{map[string]any{"name": "missing-id"}}}, + } { + if _, _, err := requireDevAppCollection(bad, "devapp/list_dev_app_members", []string{"userId"}, "members"); err == nil { + t.Fatalf("malformed member response accepted: %#v", bad) + } + } + }) + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + responses map[string][]map[string]any + wantTools []string + }{ + { + name: "robot config", declaration: RobotConfig, + args: []string{"--unified-app-id", "app", "--name", "bot", "--mode", "STREAM", "--yes"}, + responses: map[string][]map[string]any{ + "set_extension_robot_config": {{"success": true}}, + "get_extension_robot_config": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "name": "bot", "mode": "STREAM"}}}, + }, + wantTools: []string{"set_extension_robot_config", "get_extension_robot_config"}, + }, + { + name: "robot enable", declaration: RobotEnable, + args: []string{"--unified-app-id", "app", "--yes"}, + responses: map[string][]map[string]any{ + "enable_dev_app_robot": {{"success": true}}, + "get_extension_robot_config": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "robotStatus": "ONLINE"}}}, + }, + wantTools: []string{"enable_dev_app_robot", "get_extension_robot_config"}, + }, + { + name: "robot disable", declaration: RobotDisable, + args: []string{"--unified-app-id", "app", "--yes"}, + responses: map[string][]map[string]any{ + "disable_dev_app_robot": {{"success": true}}, + "get_extension_robot_config": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "robotStatus": "UNCONFIGURED"}}}, + }, + wantTools: []string{"disable_dev_app_robot", "get_extension_robot_config"}, + }, + { + name: "event subscribe", declaration: EventSubscribe, + args: []string{"--unified-app-id", "app", "--event-codes", "event.one", "--yes"}, + responses: map[string][]map[string]any{ + "subscribe_dev_app_events": {{"success": true}}, + "list_dev_app_events": {{"success": true, "list": []any{map[string]any{"eventCode": "event.one"}}, "hasMore": false}}, + }, + wantTools: []string{"subscribe_dev_app_events", "list_dev_app_events"}, + }, + { + name: "version create", declaration: VersionCreate, + args: []string{"--unified-app-id", "app", "--desc", "snapshot", "--yes"}, + responses: map[string][]map[string]any{ + "create_dev_app_version": {{"success": true, "result": map[string]any{"versionId": "version"}}}, + "get_dev_app_version_detail": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "versionId": "version", "desc": "snapshot"}}}, + }, + wantTools: []string{"create_dev_app_version", "get_dev_app_version_detail"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &devAppCoverageCaller{responses: tc.responses} + if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err != nil { + t.Fatal(err) + } + gotTools := make([]string, 0, len(caller.calls)) + for _, call := range caller.calls { + gotTools = append(gotTools, call.tool) + } + if !reflect.DeepEqual(gotTools, tc.wantTools) { + t.Fatalf("tools = %#v, want %#v", gotTools, tc.wantTools) + } + }) + } + + t.Run("event false receipt is rejected by readback", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "subscribe_dev_app_events": {{"success": true}}, + "list_dev_app_events": {{"success": true, "list": []any{}, "hasMore": false}}, + }} + if err := runDevAppCoverage(t, EventSubscribe, caller, "--unified-app-id", "app", "--event-codes", "event.one", "--yes"); err == nil { + t.Fatal("event subscription without exact readback was accepted") + } + }) +} + +func TestCrossPlatformCoverageDevAppWritesUseExactReadback(t *testing.T) { + t.Run("create", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "create_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app"}}}, + "get_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "name": "created"}}}, + }} + if err := runDevAppCoverage(t, CreateApp, caller, "--name", "created", "--yes"); err != nil { + t.Fatal(err) + } + want := []devAppCoverageCall{ + {tool: "create_dev_app", params: map[string]any{"name": "created"}}, + {tool: "get_dev_app", params: map[string]any{"unifiedAppId": "app"}}, + } + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) + + t.Run("update mismatch fails", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "update_dev_app": {{"success": true}}, + "get_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "name": "old"}}}, + }} + if err := runDevAppCoverage(t, UpdateApp, caller, "--unified-app-id", "app", "--name", "new", "--yes"); err == nil { + t.Fatal("write/readback mismatch was accepted") + } + if len(caller.calls) != 2 || caller.calls[0].tool != "update_dev_app" || caller.calls[1].tool != "get_dev_app" { + t.Fatalf("calls = %#v", caller.calls) + } + }) + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + writeTool string + status string + }{ + {name: "enable", declaration: EnableApp, writeTool: "enable_dev_app", status: "normal"}, + {name: "disable", declaration: DisableApp, writeTool: "disable_dev_app", status: "disabled"}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + tc.writeTool: {{"success": true}}, + "get_dev_app": {{"success": true, "result": map[string]any{ + "unifiedAppId": "app", "appStatus": tc.status, + }}}, + }} + if err := runDevAppCoverage(t, tc.declaration, caller, "--unified-app-id", "app", "--yes"); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 2 || caller.calls[0].tool != tc.writeTool || caller.calls[1].tool != "get_dev_app" { + t.Fatalf("calls = %#v", caller.calls) + } + }) + } + + t.Run("webapp config", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_webapp_config": {{"success": true}}, + "get_extension_webapp_config": {{"success": true, "result": map[string]any{ + "unifiedAppId": "app", "homepageUrl": "https://example.invalid", + }}}, + }} + if err := runDevAppCoverage(t, WebappConfig, caller, "--unified-app-id", "app", "--homepage-url", "https://example.invalid", "--yes"); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 2 || caller.calls[0].tool != "set_extension_webapp_config" || caller.calls[1].tool != "get_extension_webapp_config" { + t.Fatalf("calls = %#v", caller.calls) + } + }) + + t.Run("delete pre-read and absence", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "appKey": "key"}}}, + "delete_dev_app": {{"success": true}}, + "list_dev_app": {{"success": true, "list": []any{}, "hasMore": false}}, + }} + if err := runDevAppCoverage(t, DeleteApp, caller, "--unified-app-id", "app", "--yes"); err != nil { + t.Fatal(err) + } + want := []devAppCoverageCall{ + {tool: "get_dev_app", params: map[string]any{"unifiedAppId": "app"}}, + {tool: "delete_dev_app", params: map[string]any{"unifiedAppId": "app"}}, + {tool: "list_dev_app", params: map[string]any{"appKey": "key", "pageSize": 20}}, + } + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) +} + +func TestCrossPlatformCoverageDevAppReviewedInventory(t *testing.T) { + public := 0 + unavailable := 0 + total := 0 + for _, item := range shortcut.All() { + if item.Service != "devapp" { + continue + } + total++ + if item.Availability == shortcut.AvailabilityUnavailable { + unavailable++ + if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog(item.Service, item.Command) { + t.Errorf("unavailable %s visibility/public = hidden=%v compatibility=%v public=%v", + item.Command, item.Hidden, item.CompatibilityVisible, shortcut.InPublicCatalog(item.Service, item.Command)) + } + if strings.TrimSpace(devAppUnavailableReasons[item.Command]) == "" { + t.Errorf("unavailable %s lacks concrete reason", item.Command) + } + if !item.Contract.Empty() { + t.Errorf("hidden unavailable %s must not publish an incomplete Contract", item.Command) + } + continue + } + if shortcut.InPublicCatalog(item.Service, item.Command) { + public++ + if item.Hidden || item.Availability != shortcut.AvailabilityAvailable { + t.Errorf("public %s visibility/availability = %v/%q", item.Command, item.Hidden, item.Availability) + } + if item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("public %s rollout = %q", item.Command, item.OutputRollout) + } + if item.Contract.Result == nil || item.Contract.Interface == nil || item.Contract.Description == "" { + t.Errorf("public %s has incomplete Contract/Result", item.Command) + } + if item.Safety.Effect == "" || item.Safety.Risk == "" || item.Safety.Confirmation == "" || item.Safety.Idempotency == "" { + t.Errorf("public %s has incomplete Safety", item.Command) + } + } + } + if total != 30 || public != 25 || unavailable != 5 { + t.Fatalf("devapp inventory total/public/unavailable = %d/%d/%d, want 30/25/5", total, public, unavailable) + } +} + +func TestCrossPlatformCoverageDevAppUnavailableCommandsMakeZeroCalls(t *testing.T) { + for command, reason := range devAppUnavailableReasons { + declaration, found := devAppRegistered(command) + if !found { + t.Fatalf("registered unavailable command missing: %s", command) + } + args := []string{} + for _, flag := range declaration.Flags { + if !flag.Required { + continue + } + args = append(args, "--"+flag.Name) + if flag.Type != shortcut.FlagBool { + args = append(args, "fixture") + } + } + caller := &devAppCoverageCaller{} + err := runDevAppCoverage(t, declaration, caller, args...) + if err == nil || !strings.Contains(err.Error(), reason) { + t.Errorf("%s unavailable error = %v", command, err) + } + if len(caller.calls) != 0 { + t.Errorf("%s unavailable command called MCP: %#v", command, caller.calls) + } + } +} diff --git a/internal/shortcut/devapp/devapp_pagination_projection_test.go b/internal/shortcut/devapp/devapp_pagination_projection_test.go index eeb6048b..3404e162 100644 --- a/internal/shortcut/devapp/devapp_pagination_projection_test.go +++ b/internal/shortcut/devapp/devapp_pagination_projection_test.go @@ -32,12 +32,15 @@ func TestDevAppListProjectionPreservesPaginationEvidence(t *testing.T) { name string data map[string]any }{ - {name: "top level", data: map[string]any{"hasMore": true, "nextCursor": "next"}}, - {name: "nested result", data: map[string]any{"result": map[string]any{"hasMore": true, "nextCursor": "next"}}}, - {name: "exhausted", data: map[string]any{"hasMore": false}}, + {name: "top level", data: map[string]any{"success": true, "hasMore": true, "nextCursor": "next"}}, + {name: "nested result", data: map[string]any{"success": true, "result": map[string]any{"hasMore": true, "nextCursor": "next"}}}, + {name: "exhausted", data: map[string]any{"success": true, "hasMore": false}}, } { t.Run(tc.name, func(t *testing.T) { - got := devAppListProjection(tc.data, "items", items) + got, err := devAppListProjection(tc.data, "items", items, "devapp/test") + if err != nil { + t.Fatal(err) + } if got["hasMore"] != devAppPaginationCandidates(tc.data)[len(devAppPaginationCandidates(tc.data))-1]["hasMore"] && tc.name == "nested result" { t.Fatalf("hasMore not preserved: %#v", got) } @@ -52,14 +55,23 @@ func TestDevAppListProjectionPreservesPaginationEvidence(t *testing.T) { if got := devAppPaginationCandidates(nil); got != nil { t.Fatalf("nil candidates=%#v", got) } - deep := map[string]any{"content": map[string]any{"result": map[string]any{"hasMore": true, "nextCursor": "deep"}}} - if got := devAppListProjection(deep, "items", nil); got["nextCursor"] != "deep" { + deep := map[string]any{"content": map[string]any{"success": true, "result": map[string]any{"hasMore": true, "nextCursor": "deep"}}} + if got, err := devAppListProjection(deep, "items", nil, "devapp/test"); err != nil || got["nextCursor"] != "deep" { t.Fatalf("deep projection=%#v", got) } + deeper := map[string]any{"success": true, "content": map[string]any{"result": map[string]any{"data": map[string]any{"hasMore": true, "nextCursor": "deeper"}}}} + if got, err := devAppListProjection(deeper, "items", nil, "devapp/test"); err != nil || got["nextCursor"] != "deeper" { + t.Fatalf("deeper projection=%#v", got) + } + cyclic := map[string]any{"success": true} + cyclic["content"] = cyclic + if got := devAppPaginationCandidates(cyclic); len(got) != 13 { + t.Fatalf("cyclic candidate walk length=%d, want bounded length 13", len(got)) + } } func TestFrameworkPaginatedShortcutExecutionMovesCursorToMeta(t *testing.T) { - caller := &paginationCaller{text: `{"content":{"result":{"hasMore":true,"nextCursor":"next","list":[]}}}`} + caller := &paginationCaller{text: `{"content":{"success":true,"result":{"hasMore":true,"nextCursor":"next","list":[]}}}`} helpers.InitDepsForTest(t, caller) helpers.GetFormatter().SetWriters(&bytes.Buffer{}, &bytes.Buffer{}) for _, tc := range []struct { diff --git a/internal/shortcut/devapp/devapp_rollout_test.go b/internal/shortcut/devapp/devapp_rollout_test.go index d89ac792..dd46a96e 100644 --- a/internal/shortcut/devapp/devapp_rollout_test.go +++ b/internal/shortcut/devapp/devapp_rollout_test.go @@ -12,10 +12,16 @@ import ( func TestDevAppShortcutsRollOutPerTerminalCommand(t *testing.T) { active := map[string]bool{ - "+list": true, "+get": true, "+webapp-get": true, - "+permission-list": true, "+event-list": true, "+version-list": true, + "+list": true, "+get": true, "+create": true, "+update": true, + "+delete": true, "+enable": true, "+disable": true, "+webapp-get": true, + "+webapp-config": true, + "+permission-list": true, "+version-list": true, "+robot-get": true, "+version-get": true, "+version-check-approval": true, "+version-status": true, + "+credentials-get": true, "+member-list": true, + "+member-add": true, "+member-remove": true, + "+robot-config": true, "+robot-enable": true, "+robot-disable": true, + "+event-list": true, "+event-subscribe": true, "+version-create": true, } seen := map[string]bool{} for _, item := range shortcut.All() { @@ -42,7 +48,7 @@ func TestDevAppShortcutsRollOutPerTerminalCommand(t *testing.T) { t.Errorf("active pilot shortcut %s is not registered", name) } } - for _, paginated := range []string{"+list", "+permission-list", "+event-list", "+version-list"} { + for _, paginated := range []string{"+list", "+permission-list", "+version-list"} { if !seen[paginated] { t.Errorf("paginated shortcut %s is not registered", paginated) } diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 081b94a9..dd44d89d 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -6,6 +6,11 @@ package shortcut // command discovery and skill generation. func generatedPublicShortcutCatalog() map[string]struct{} { return map[string]struct{}{ + "agoal\u0000+contract-fields": {}, + "agoal\u0000+obj-template-list": {}, + "agoal\u0000+report-statistics-list": {}, + "agoal\u0000+report-submit-detail": {}, + "agoal\u0000+user-rules": {}, "aisearch\u0000+search-person": {}, "aitable\u0000+advperm-disable": {}, "aitable\u0000+advperm-enable": {}, @@ -254,19 +259,25 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "contact\u0000+search-user": {}, "contact\u0000+team": {}, "devapp\u0000+create": {}, + "devapp\u0000+credentials-get": {}, "devapp\u0000+delete": {}, "devapp\u0000+disable": {}, "devapp\u0000+enable": {}, "devapp\u0000+event-list": {}, + "devapp\u0000+event-subscribe": {}, "devapp\u0000+get": {}, "devapp\u0000+list": {}, "devapp\u0000+member-add": {}, "devapp\u0000+member-list": {}, "devapp\u0000+member-remove": {}, "devapp\u0000+permission-list": {}, + "devapp\u0000+robot-config": {}, + "devapp\u0000+robot-disable": {}, + "devapp\u0000+robot-enable": {}, "devapp\u0000+robot-get": {}, "devapp\u0000+update": {}, "devapp\u0000+version-check-approval": {}, + "devapp\u0000+version-create": {}, "devapp\u0000+version-get": {}, "devapp\u0000+version-list": {}, "devapp\u0000+version-status": {}, diff --git a/internal/shortcut/runner.go b/internal/shortcut/runner.go index f265ce60..6e50f376 100644 --- a/internal/shortcut/runner.go +++ b/internal/shortcut/runner.go @@ -336,6 +336,23 @@ func (rt *RuntimeContext) Output(payload any) error { return output.WriteCommandPayload(rt.cmd, payload, output.FormatJSON) } +// OutputForTool stores a once-fetched MCP payload while retaining the tool name +// for product-specific outcome normalization. It is intended for composite +// shortcuts that must validate stable identity or collection shape before the +// shared renderer sees the response; callers must not use it to issue a second +// business request. +func (rt *RuntimeContext) OutputForTool(tool string, payload any) error { + if output.UsesUnifiedResult(rt.cmd) { + return output.StoreResult(rt.cmd.Context(), rt.resultForPayload(tool, payload)) + } + if output.CommandRollout(rt.cmd) == output.RolloutDualValidate { + if err := validateShadowResult(rt.resultForPayload(tool, payload)); err != nil { + return err + } + } + return output.WriteCommandPayload(rt.cmd, payload, output.FormatJSON) +} + func (rt *RuntimeContext) storePayload(tool string, payload any) error { return output.StoreResult(rt.cmd.Context(), rt.resultForPayload(tool, payload)) } diff --git a/internal/shortcut/runner_coverage_test.go b/internal/shortcut/runner_coverage_test.go index bb83d228..9d2ce47b 100644 --- a/internal/shortcut/runner_coverage_test.go +++ b/internal/shortcut/runner_coverage_test.go @@ -14,6 +14,7 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -231,3 +232,53 @@ func TestFrameworkShortcutUnifiedOutputAndProjectionEdges(t *testing.T) { t.Fatalf("devapp result=%s", got.Outcome()) } } + +func TestCrossPlatformCoverageRuntimeOutputForToolRollouts(t *testing.T) { + t.Run("unified", func(t *testing.T) { + ctx, _ := output.WithResultStore(context.Background()) + cmd := &cobra.Command{Use: "+get"} + cmd.SetContext(ctx) + output.SetCommandRollout(cmd, output.RolloutUnifiedActive) + rt := RuntimeContextForTest(cmd, Shortcut{Service: "devapp", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}}) + if err := rt.OutputForTool("get_dev_app", map[string]any{"success": true, "result": map[string]any{"unifiedAppId": "app"}}); err != nil { + t.Fatal(err) + } + }) + + t.Run("dual validate", func(t *testing.T) { + cmd := &cobra.Command{Use: "+get"} + cmd.SetContext(context.Background()) + cmd.SetOut(&bytes.Buffer{}) + output.SetCommandRollout(cmd, output.RolloutDualValidate) + rt := RuntimeContextForTest(cmd, Shortcut{Service: "devapp", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}}) + if err := rt.OutputForTool("get_dev_app", map[string]any{"success": true, "result": map[string]any{"unifiedAppId": "app"}}); err != nil { + t.Fatal(err) + } + }) + + t.Run("dual validate failure", func(t *testing.T) { + testseam.Swap(t, &validateShadowResult, func(output.CommandResult) error { return context.Canceled }) + cmd := &cobra.Command{Use: "+get"} + cmd.SetContext(context.Background()) + output.SetCommandRollout(cmd, output.RolloutDualValidate) + rt := RuntimeContextForTest(cmd, Shortcut{Service: "devapp", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}}) + if err := rt.OutputForTool("get_dev_app", map[string]any{"success": true}); !errors.Is(err, context.Canceled) { + t.Fatalf("dual validation error=%v", err) + } + }) + + t.Run("legacy", func(t *testing.T) { + cmd := &cobra.Command{Use: "+get"} + cmd.SetContext(context.Background()) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + output.SetCommandRollout(cmd, output.RolloutLegacyOnly) + rt := RuntimeContextForTest(cmd, Shortcut{Service: "sample", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}}) + if err := rt.OutputForTool("get", map[string]any{"id": "item"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), `"id"`) { + t.Fatalf("legacy output=%q", stdout.String()) + } + }) +} diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index b7584c43..4713ad3f 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -73,6 +73,12 @@ var hrbrainSemanticCatalogJSON []byte //go:embed semantic_catalog_pat.json var patSemanticCatalogJSON []byte +//go:embed semantic_catalog_devapp.json +var devAppSemanticCatalogJSON []byte + +//go:embed semantic_catalog_agoal.json +var agoalSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -113,6 +119,8 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs( devdocSemanticCatalogJSON, hrbrainSemanticCatalogJSON, patSemanticCatalogJSON, + devAppSemanticCatalogJSON, + agoalSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { diff --git a/internal/shortcut/semantic_catalog_agoal.json b/internal/shortcut/semantic_catalog_agoal.json new file mode 100644 index 00000000..9db46f1f --- /dev/null +++ b/internal/shortcut/semantic_catalog_agoal.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "service": "agoal", + "default_availability": "available", + "shortcuts": { + "+report-statistics-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、content 数组和每项稳定 templateId;关键词同时支持已知非空与合法零命中。","risk":"read","public":true,"reviewed":true}, + "+obj-template-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、result 数组、稳定模板 ID 及 page/pageSize/totalCount;不虚构 cursor。","risk":"read","public":true,"reviewed":true}, + "+strategy-list": {"disposition":"schema_leaf","semantic_delta":"exact+raw 在可访问 PERSONAL/DEPT 范围均仅为空,缺少已知非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+strategy-get": {"disposition":"schema_leaf","semantic_delta":"不存在 ID 的 raw 响应为无 success 的 null,且列表无非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+strategy-update": {"disposition":"schema_leaf","semantic_delta":"仅通过 raw dry-run;缺少旧值、全字段读回和恢复。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true}, + "+contract-list": {"disposition":"schema_leaf","semantic_delta":"exact+raw 在可访问 PERSONAL/DEPT 范围均仅为空,缺少已知非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+contract-fields": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、字段数组和每项稳定 id;本地 keyword 覆盖字段标识、编码、标题、分类和类型,支持已知非空与保证零命中。","risk":"read","public":true,"reviewed":true}, + "+contract-get": {"disposition":"schema_leaf","semantic_delta":"不存在 ID 的 raw 响应为无 success 的 null,且列表无非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+contract-update": {"disposition":"schema_leaf","semantic_delta":"仅通过 raw dry-run;缺少旧值、全字段读回和恢复。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true}, + "+scorecard-get": {"disposition":"schema_leaf","semantic_delta":"可访问部门与多个周期的 exact+raw 均返回无 success 的 null。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+scorecard-entity-get": {"disposition":"schema_leaf","semantic_delta":"不存在双 ID 的 raw 响应为无 success 的 null,缺少安全实体 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+scorecard-update": {"disposition":"schema_leaf","semantic_delta":"仅通过 raw dry-run;缺少计分卡旧值、全字段读回和恢复。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true}, + "+user-rules": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、content 对象、rules 数组和稳定 ruleId;本地精确 ruleId 选择器支持已知非空与保证零命中。","risk":"read","public":true,"reviewed":true}, + "+user-objectives": {"disposition":"schema_leaf","semantic_delta":"当前用户全部可见规则/周期的 exact+raw 均只为空,缺少非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+report-submit-detail": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、嵌套稳定用户身份及 page/pageSize/totalCount;下游忽略 keyword,因此做有界全量遍历、本地过滤、停滞/重复/总数变化失败与人员字段最小投影。","risk":"read","public":true,"reviewed":true}, + "+obj-template-upsert": {"disposition":"schema_leaf","semantic_delta":"仅通过 raw dry-run;无删除或版本恢复,无法验证 upsert 终态与零残留。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true} + } +} diff --git a/internal/shortcut/semantic_catalog_devapp.json b/internal/shortcut/semantic_catalog_devapp.json new file mode 100644 index 00000000..886d6ab2 --- /dev/null +++ b/internal/shortcut/semantic_catalog_devapp.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "service": "devapp", + "default_availability": "available", + "shortcuts": { + "+list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、应用数组、稳定 unifiedAppId 与游标终止证据;投影当前页并保留可续翻 meta.pagination。","risk":"read","public":true,"reviewed":true}, + "+get": {"disposition":"semantic_adapter","semantic_delta":"拒绝空响应、缺失 success 和空业务对象,并要求读回 unifiedAppId 与请求精确一致。","risk":"read","public":true,"reviewed":true}, + "+create": {"disposition":"semantic_adapter","semantic_delta":"创建后提取稳定 unifiedAppId,并以同一 ID 读回名称及请求字段;只有精确核验通过才返回成功。","risk":"write","public":true,"reviewed":true}, + "+update": {"disposition":"semantic_adapter","semantic_delta":"要求至少一个更新字段,写入后按同一 unifiedAppId 精确读回所有请求字段。","risk":"write","public":true,"reviewed":true}, + "+delete": {"disposition":"semantic_adapter","semantic_delta":"删除前读回稳定 appKey,删除后按该选择器有界遍历全部页并证明同一 unifiedAppId 不再存在。","risk":"high-risk-write","public":true,"reviewed":true}, + "+enable": {"disposition":"semantic_adapter","semantic_delta":"启用后按同一 unifiedAppId 读回并要求 appStatus=normal。","risk":"write","public":true,"reviewed":true}, + "+disable": {"disposition":"semantic_adapter","semantic_delta":"停用后按同一 unifiedAppId 读回并要求 appStatus=disabled。","risk":"high-risk-write","public":true,"reviewed":true}, + "+credentials-get": {"disposition":"semantic_adapter","semantic_delta":"严格要求 success、稳定 unifiedAppId、非空客户端标识和非空 secret;Result 将密钥路径声明为敏感。","risk":"read","public":true,"reviewed":true}, + "+webapp-get": {"disposition":"semantic_adapter","semantic_delta":"严格验证非空网页配置对象,并要求读回 unifiedAppId 与请求精确一致。","risk":"read","public":true,"reviewed":true}, + "+webapp-config": {"disposition":"semantic_adapter","semantic_delta":"拒绝空更新,写入后按同一 unifiedAppId 读取网页配置并精确比较全部请求字段。","risk":"write","public":true,"reviewed":true}, + "+permission-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、权限数组、每项稳定 scopeValue 与游标终止证据,拒绝坏元素和伪空结果。","risk":"read","public":true,"reviewed":true}, + "+permission-add": {"disposition":"schema_leaf","semantic_delta":"下游缺少逐项终态回执与按 scopeValue 精确读回验证,执行入口保持 unavailable。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+permission-remove": {"disposition":"schema_leaf","semantic_delta":"下游缺少逐项终态回执与按 scopeValue 精确读回验证,执行入口保持 unavailable。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true}, + "+member-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、成员数组和每项稳定 userId;新增本地精确 userId 选择器以证明已知非空与保证零命中。","risk":"read","public":true,"reviewed":true}, + "+member-add": {"disposition":"semantic_adapter","semantic_delta":"拒绝空值和重复 userId,校验写终态后按稳定 userId 逐项精确读回,并要求 memberType 与请求角色一致。","risk":"write","public":true,"reviewed":true}, + "+member-remove": {"disposition":"semantic_adapter","semantic_delta":"拒绝空值和重复 userId,校验写终态后读取完整成员数组,并逐项证明目标稳定 userId 已不存在。","risk":"high-risk-write","public":true,"reviewed":true}, + "+security-config": {"disposition":"schema_leaf","semantic_delta":"整组覆盖写入缺少读取旧值、精确写回和恢复原配置的安全闭环,执行入口保持 unavailable。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+robot-get": {"disposition":"semantic_adapter","semantic_delta":"严格验证非空机器人配置对象,并要求读回 unifiedAppId 与请求精确一致。","risk":"read","public":true,"reviewed":true}, + "+robot-config": {"disposition":"semantic_adapter","semantic_delta":"拒绝空配置,写后按同一 unifiedAppId 读取机器人对象并精确比较全部请求标量字段。","risk":"write","public":true,"reviewed":true}, + "+robot-enable": {"disposition":"semantic_adapter","semantic_delta":"要求写终态成功并按同一 unifiedAppId 读回 robotStatus=ONLINE。","risk":"write","public":true,"reviewed":true}, + "+robot-disable": {"disposition":"semantic_adapter","semantic_delta":"要求写终态成功并读回 robotStatus=UNCONFIGURED;不虚构保留配置或可直接恢复语义。","risk":"high-risk-write","public":true,"reviewed":true}, + "+event-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、事件数组、每项稳定 eventCode 与游标终止证据;明确返回可用事件目录及订阅状态,并拒绝坏元素与伪空结果。","risk":"read","public":true,"reviewed":true}, + "+event-subscribe": {"disposition":"semantic_adapter","semantic_delta":"拒绝空值与重复 eventCode,写后有界遍历订阅列表并逐项精确读回。","risk":"write","public":true,"reviewed":true}, + "+event-unsubscribe": {"disposition":"schema_leaf","semantic_delta":"缺少可恢复的稳定订阅 fixture 与逐项退订读回,执行入口保持 unavailable。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true}, + "+version-create": {"disposition":"semantic_adapter","semantic_delta":"创建回执必须含稳定 versionId,随后以 unifiedAppId/versionId 双身份读取详情并核验请求字段;live fixture 通过删除临时父应用清理。","risk":"write","public":true,"reviewed":true}, + "+version-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、版本数组、每项稳定 versionId 与游标终止证据。","risk":"read","public":true,"reviewed":true}, + "+version-get": {"disposition":"semantic_adapter","semantic_delta":"严格验证非空版本对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。","risk":"read","public":true,"reviewed":true}, + "+version-check-approval": {"disposition":"semantic_adapter","semantic_delta":"只执行 precheckOnly,严格绑定应用与版本身份,并保留可执行后续动作的 pending 结果。","risk":"read","public":true,"reviewed":true}, + "+version-publish": {"disposition":"schema_leaf","semantic_delta":"可能进入审批或线上生效,缺少安全测试租户与回滚合同,执行入口保持 unavailable。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+version-status": {"disposition":"semantic_adapter","semantic_delta":"严格验证非空发布状态对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。","risk":"read","public":true,"reviewed":true} + } +} diff --git a/scripts/dev/dev-devapp-agoal-shortcut-dual-live-gate.sh b/scripts/dev/dev-devapp-agoal-shortcut-dual-live-gate.sh new file mode 100755 index 00000000..d9bb9a97 --- /dev/null +++ b/scripts/dev/dev-devapp-agoal-shortcut-dual-live-gate.sh @@ -0,0 +1,798 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Opt-in, real-data release evidence gate for the Dev -> DevApp -> Agoal +# Shortcut delivery group. The current worktree binary supplies both exact +# `+shortcut` leaves and their owning atomic command trees. Raw responses and +# stable IDs stay in a repository-external temporary directory; stdout contains +# only PII-free PASS labels and aggregate facts. + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +dws_bin=${DWS_SHORTCUT_GATE_BIN:-"$repo_root/dws"} +selected_product=${1:-all} + +case "$selected_product" in + all | dev | devapp | agoal) ;; + *) + echo "usage: $0 [all|dev|devapp|agoal]" >&2 + exit 2 + ;; +esac + +if [[ ${DWS_SHORTCUT_LIVE_GATE:-} != 1 ]]; then + echo "set DWS_SHORTCUT_LIVE_GATE=1 to authorize the real-data dual-layer gate" >&2 + exit 2 +fi +if [[ $selected_product == all || $selected_product == devapp ]]; then + if [[ ! -t 0 ]]; then + echo "the DevApp live gate requires an interactive terminal for explicit write and cleanup confirmation" >&2 + exit 2 + fi + read -r -p "This gate creates, changes, and deletes isolated DevApp fixtures. Type RUN to authorize the writes and cleanup: " live_confirmation + if [[ $live_confirmation != RUN ]]; then + echo "live gate authorization declined" >&2 + exit 2 + fi +fi +if [[ ! -x $dws_bin ]]; then + echo "current worktree binary is missing; run make build first" >&2 + exit 2 +fi +command -v jq >/dev/null 2>&1 || { + echo "jq is required" >&2 + exit 2 +} + +member_user_id="" +member_type=${DWS_DEVAPP_E2E_MEMBER_TYPE:-DEVELOPER} +if [[ $selected_product == all || $selected_product == devapp ]]; then + member_user_id_file=${DWS_DEVAPP_E2E_MEMBER_USER_ID_FILE:-} + if [[ -z $member_user_id_file || ! -f $member_user_id_file ]]; then + echo "set DWS_DEVAPP_E2E_MEMBER_USER_ID_FILE to a private file containing one safe test-account userId" >&2 + exit 2 + fi + IFS= read -r member_user_id <"$member_user_id_file" || true + [[ -n $member_user_id ]] || { + echo "the private DevApp member fixture is empty" >&2 + exit 2 + } + [[ -n $member_type ]] || { + echo "DWS_DEVAPP_E2E_MEMBER_TYPE must not be empty" >&2 + exit 2 + } +fi + +evidence_dir=$(mktemp -d "${TMPDIR:-/tmp}/dws-shortcut-dual-live.XXXXXX") +exact_app_id="" +exact_app_name="" +raw_app_id="" +raw_app_name="" + +fail() { + echo "FAIL $1 (secure response retained only until trap cleanup)" >&2 + exit 1 +} + +run_json() { + local label=$1 + local output=$2 + shift 2 + if ! "$@" --format json >"$output" 2>"$output.stderr"; then + fail "$label" + fi + jq -e . "$output" >/dev/null 2>&1 || fail "$label: non-JSON response" +} + +assert_jq() { + local label=$1 + local expression=$2 + shift 2 + jq -e "$expression" "$@" >/dev/null 2>&1 || fail "$label" +} + +pass() { + printf 'PASS %s\n' "$1" +} + +assert_public_surface() { + local product=$1 + local expected=$2 + local actual + actual=$(jq -r --arg product "$product" '.results[] | select(.service == $product) | .command' "$repo_root/docs/shortcut-public-catalog.json" | LC_ALL=C sort) + expected=$(printf '%s\n' "$expected" | sed '/^$/d' | LC_ALL=C sort) + [[ $actual == "$expected" ]] || fail "$product public catalog differs from the exhaustive live matrix" +} + +cleanup() { + set +e + if [[ -n $exact_app_id ]]; then + "$dws_bin" devapp +delete --unified-app-id "$exact_app_id" --yes --format json >/dev/null 2>&1 + fi + if [[ -n $raw_app_id ]]; then + "$dws_bin" dev app delete --unified-app-id "$raw_app_id" --confirm-name "$raw_app_name" --yes --format json >/dev/null 2>&1 + fi + rm -rf "$evidence_dir" +} +trap cleanup EXIT INT TERM + +run_dev_gate() { + # Dev owns routing/documentation only and registers no Shortcut leaves. + assert_public_surface dev "" + pass "DEV-INVENTORY public=0 fully-unlocked=0 hardened-blocked=0 routed=4 platform-unavailable=1" +} + +expect_confirmation() { + local label=$1 + local output=$2 + shift 2 + local status=0 + # The outer gate intentionally requires a TTY for one explicit RUN + # authorization. The command under test must not inherit that TTY here: + # otherwise its own confirmation prompt blocks instead of returning the + # machine-readable confirmation_required result that proves zero writes. + "$@" --format json "$output" 2>"$output.stderr" || status=$? + [[ $status -ne 0 ]] || fail "$label: unconfirmed command unexpectedly succeeded" + jq -e '.ok == false and .error.type == "validation" and .error.subtype == "confirmation_required"' "$output" >/dev/null 2>&1 || fail "$label: missing confirmation_required" +} + +run_devapp_gate() { + assert_public_surface devapp "+create ++credentials-get ++delete ++disable ++enable ++event-list ++event-subscribe ++get ++list ++member-add ++member-list ++member-remove ++permission-list ++robot-config ++robot-disable ++robot-enable ++robot-get ++update ++version-check-approval ++version-create ++version-get ++version-list ++version-status ++webapp-config ++webapp-get" + local nonce="DWS-GUARANTEED-ZERO-8F3D2C4A" + local suffix="$(date +%s)-$$" + exact_app_name="DWS-E2E-EXACT-$suffix" + raw_app_name="DWS-E2E-RAW-$suffix" + local exact_updated_name="DWS-E2E-UPDATED-$suffix" + local raw_updated_name="DWS-E2E-RAW-UPDATED-$suffix" + local bot_raw="DWS-E2E-BOT-RAW-$suffix" + local bot_exact="DWS-E2E-BOT-EXACT-$suffix" + local event_raw="bpms_task_change" + local event_exact="$event_raw" + + run_json "devapp create guard before exact query" "$evidence_dir/devapp-create-guard-before-exact.json" \ + "$dws_bin" devapp +list --name "$exact_app_name" --page-size 20 + run_json "devapp create guard before raw query" "$evidence_dir/devapp-create-guard-before-raw.json" \ + "$dws_bin" dev app list --name "$exact_app_name" --page-size 20 + expect_confirmation "devapp +create confirmation" "$evidence_dir/devapp-create-confirm.json" \ + "$dws_bin" devapp +create --name "$exact_app_name" + run_json "devapp create guard after exact query" "$evidence_dir/devapp-create-guard-after-exact.json" \ + "$dws_bin" devapp +list --name "$exact_app_name" --page-size 20 + run_json "devapp create guard after raw query" "$evidence_dir/devapp-create-guard-after-raw.json" \ + "$dws_bin" dev app list --name "$exact_app_name" --page-size 20 + assert_jq "devapp +create confirmation zero-call state" ' + (input) as $br | (input) as $ae | (input) as $ar | + .data.count == 0 and ($br.data.items | length) == 0 and + $ae.data.count == 0 and ($ar.data.items | length) == 0 + ' "$evidence_dir/devapp-create-guard-before-exact.json" "$evidence_dir/devapp-create-guard-before-raw.json" \ + "$evidence_dir/devapp-create-guard-after-exact.json" "$evidence_dir/devapp-create-guard-after-raw.json" + + run_json "devapp exact create" "$evidence_dir/devapp-create-exact.json" \ + "$dws_bin" devapp +create --name "$exact_app_name" --desc "dual-live-exact" --yes + exact_app_id=$(jq -er '.data.unifiedAppId | select(type == "string" and length > 0)' "$evidence_dir/devapp-create-exact.json") || fail "devapp exact create stable ID" + assert_jq "devapp exact create terminal receipt" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "create"' "$evidence_dir/devapp-create-exact.json" + run_json "devapp exact create raw readback" "$evidence_dir/devapp-create-exact-raw-readback.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" --arg name "$exact_app_name" '.ok == true and .outcome == "success" and .data.unifiedAppId == $id and .data.name == $name' "$evidence_dir/devapp-create-exact-raw-readback.json" >/dev/null || fail "devapp exact create raw readback" + + run_json "devapp raw create" "$evidence_dir/devapp-create-raw.json" \ + "$dws_bin" dev app create --name "$raw_app_name" --desc "dual-live-raw" --yes + raw_app_id=$(jq -er '.data.unifiedAppId | select(type == "string" and length > 0)' "$evidence_dir/devapp-create-raw.json") || fail "devapp raw create stable ID" + assert_jq "devapp raw create terminal receipt" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-create-raw.json" + run_json "devapp raw create exact readback" "$evidence_dir/devapp-create-raw-exact-readback.json" \ + "$dws_bin" devapp +get --unified-app-id "$raw_app_id" + jq -e --arg id "$raw_app_id" --arg name "$raw_app_name" '.ok == true and .outcome == "success" and .data.unifiedAppId == $id and .data.name == $name' "$evidence_dir/devapp-create-raw-exact-readback.json" >/dev/null || fail "devapp raw create exact readback" + pass "DEVAPP +create confirm0/exact+raw terminal-receipt/stable-ID readback" + + run_json "devapp guard snapshot app" "$evidence_dir/devapp-guard-app-before.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + run_json "devapp guard snapshot webapp" "$evidence_dir/devapp-guard-webapp-before.json" \ + "$dws_bin" dev app webapp get --unified-app-id "$exact_app_id" + run_json "devapp guard snapshot robot" "$evidence_dir/devapp-guard-robot-before.json" \ + "$dws_bin" dev app robot get --unified-app-id "$exact_app_id" + run_json "devapp guard snapshot events" "$evidence_dir/devapp-guard-events-before.json" \ + "$dws_bin" dev app event list --unified-app-id "$exact_app_id" --page-size 100 + run_json "devapp guard snapshot versions" "$evidence_dir/devapp-guard-versions-before.json" \ + "$dws_bin" dev app version list --unified-app-id "$exact_app_id" --page-size 100 + + expect_confirmation "devapp +update confirmation" "$evidence_dir/devapp-update-confirm.json" \ + "$dws_bin" devapp +update --unified-app-id "$exact_app_id" --name "guard-must-not-write" + expect_confirmation "devapp +delete confirmation" "$evidence_dir/devapp-delete-confirm.json" \ + "$dws_bin" devapp +delete --unified-app-id "$exact_app_id" + expect_confirmation "devapp +enable confirmation" "$evidence_dir/devapp-enable-confirm.json" \ + "$dws_bin" devapp +enable --unified-app-id "$exact_app_id" + expect_confirmation "devapp +disable confirmation" "$evidence_dir/devapp-disable-confirm.json" \ + "$dws_bin" devapp +disable --unified-app-id "$exact_app_id" + expect_confirmation "devapp +webapp-config confirmation" "$evidence_dir/devapp-webapp-confirm.json" \ + "$dws_bin" devapp +webapp-config --unified-app-id "$exact_app_id" --homepage-url "https://example.invalid/guard" + expect_confirmation "devapp +robot-config confirmation" "$evidence_dir/devapp-robot-config-confirm.json" \ + "$dws_bin" devapp +robot-config --unified-app-id "$exact_app_id" --name "guard-must-not-write" --mode STREAM + expect_confirmation "devapp +robot-enable confirmation" "$evidence_dir/devapp-robot-enable-confirm.json" \ + "$dws_bin" devapp +robot-enable --unified-app-id "$exact_app_id" + expect_confirmation "devapp +robot-disable confirmation" "$evidence_dir/devapp-robot-disable-confirm.json" \ + "$dws_bin" devapp +robot-disable --unified-app-id "$exact_app_id" + expect_confirmation "devapp +event-subscribe confirmation" "$evidence_dir/devapp-event-confirm.json" \ + "$dws_bin" devapp +event-subscribe --unified-app-id "$exact_app_id" --event-codes "$event_exact" + expect_confirmation "devapp +version-create confirmation" "$evidence_dir/devapp-version-create-confirm.json" \ + "$dws_bin" devapp +version-create --unified-app-id "$exact_app_id" --desc "guard-must-not-write" + + run_json "devapp guard snapshot app after" "$evidence_dir/devapp-guard-app-after.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + run_json "devapp guard snapshot webapp after" "$evidence_dir/devapp-guard-webapp-after.json" \ + "$dws_bin" dev app webapp get --unified-app-id "$exact_app_id" + run_json "devapp guard snapshot robot after" "$evidence_dir/devapp-guard-robot-after.json" \ + "$dws_bin" dev app robot get --unified-app-id "$exact_app_id" + run_json "devapp guard snapshot events after" "$evidence_dir/devapp-guard-events-after.json" \ + "$dws_bin" dev app event list --unified-app-id "$exact_app_id" --page-size 100 + run_json "devapp guard snapshot versions after" "$evidence_dir/devapp-guard-versions-after.json" \ + "$dws_bin" dev app version list --unified-app-id "$exact_app_id" --page-size 100 + assert_jq "devapp all public write confirmations preserve raw state" ' + (input) as $aa | (input) as $wb | (input) as $wa | (input) as $rb | (input) as $ra | + (input) as $eb | (input) as $ea | (input) as $vb | (input) as $va | + [.data.unifiedAppId,.data.name,.data.desc,.data.appStatus] == [$aa.data.unifiedAppId,$aa.data.name,$aa.data.desc,$aa.data.appStatus] and + $wb.data == $wa.data and $rb.data == $ra.data and + ([$eb.data.events[].eventCode] | sort) == ([$ea.data.events[].eventCode] | sort) and + ([$vb.data.items[].versionId] | sort) == ([$va.data.items[].versionId] | sort) + ' "$evidence_dir/devapp-guard-app-before.json" "$evidence_dir/devapp-guard-app-after.json" \ + "$evidence_dir/devapp-guard-webapp-before.json" "$evidence_dir/devapp-guard-webapp-after.json" \ + "$evidence_dir/devapp-guard-robot-before.json" "$evidence_dir/devapp-guard-robot-after.json" \ + "$evidence_dir/devapp-guard-events-before.json" "$evidence_dir/devapp-guard-events-after.json" \ + "$evidence_dir/devapp-guard-versions-before.json" "$evidence_dir/devapp-guard-versions-after.json" + + run_json "devapp exact list known" "$evidence_dir/devapp-list-known-exact.json" \ + "$dws_bin" devapp +list --name "$exact_app_name" --page-size 20 + run_json "devapp raw list known" "$evidence_dir/devapp-list-known-raw.json" \ + "$dws_bin" dev app list --name "$exact_app_name" --page-size 20 + run_json "devapp exact list zero" "$evidence_dir/devapp-list-zero-exact.json" \ + "$dws_bin" devapp +list --name "$nonce" --page-size 20 + run_json "devapp raw list zero" "$evidence_dir/devapp-list-zero-raw.json" \ + "$dws_bin" dev app list --name "$nonce" --page-size 20 + jq -e --arg id "$exact_app_id" ' + (input) as $raw | (input) as $ze | (input) as $zr | + .ok == true and .outcome == "success" and .data.count == 1 and .data.apps[0].unifiedAppId == $id and + $raw.ok == true and $raw.outcome == "success" and ($raw.data.items | length) == 1 and $raw.data.items[0].unifiedAppId == $id and + $ze.ok == true and $ze.data.count == 0 and ($ze.data.apps | length) == 0 and + $zr.ok == true and ($zr.data.items | length) == 0 and + .meta.pagination.endpoint_exhausted == true and $raw.meta.pagination.endpoint_exhausted == true and + $ze.meta.pagination.endpoint_exhausted == true and $zr.meta.pagination.endpoint_exhausted == true + ' "$evidence_dir/devapp-list-known-exact.json" "$evidence_dir/devapp-list-known-raw.json" \ + "$evidence_dir/devapp-list-zero-exact.json" "$evidence_dir/devapp-list-zero-raw.json" >/dev/null || fail "devapp +list dual known/zero alignment" + pass "DEVAPP +list exact+raw known-nonempty/guaranteed-zero stable-ID/cursor termination" + + run_json "devapp exact get" "$evidence_dir/devapp-get-exact.json" \ + "$dws_bin" devapp +get --unified-app-id "$exact_app_id" + run_json "devapp raw get" "$evidence_dir/devapp-get-raw.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" '(input) as $raw | .ok == true and .data.unifiedAppId == $id and $raw.ok == true and $raw.data.unifiedAppId == $id and .data.appKey == $raw.data.appKey' "$evidence_dir/devapp-get-exact.json" "$evidence_dir/devapp-get-raw.json" >/dev/null || fail "devapp +get dual object read" + pass "DEVAPP +get exact+raw stable-application-ID object alignment" + + run_json "devapp raw update" "$evidence_dir/devapp-update-raw.json" \ + "$dws_bin" dev app update --unified-app-id "$exact_app_id" --name "$raw_updated_name" --desc "raw-update" --yes + assert_jq "devapp raw update terminal receipt" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-update-raw.json" + run_json "devapp raw update exact readback" "$evidence_dir/devapp-update-raw-exact-readback.json" \ + "$dws_bin" devapp +get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" --arg name "$raw_updated_name" '.ok == true and .data.unifiedAppId == $id and .data.name == $name and .data.desc == "raw-update"' "$evidence_dir/devapp-update-raw-exact-readback.json" >/dev/null || fail "devapp raw update exact readback" + run_json "devapp exact update" "$evidence_dir/devapp-update-exact.json" \ + "$dws_bin" devapp +update --unified-app-id "$exact_app_id" --name "$exact_updated_name" --desc "exact-update" --yes + assert_jq "devapp exact update terminal receipt" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "update"' "$evidence_dir/devapp-update-exact.json" + run_json "devapp exact update raw readback" "$evidence_dir/devapp-update-exact-raw-readback.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" --arg name "$exact_updated_name" '.ok == true and .data.unifiedAppId == $id and .data.name == $name and .data.desc == "exact-update"' "$evidence_dir/devapp-update-exact-raw-readback.json" >/dev/null || fail "devapp exact update raw readback" + pass "DEVAPP +update confirm0/exact+raw terminal-receipt/key-field readback" + + run_json "devapp raw disable" "$evidence_dir/devapp-disable-raw.json" \ + "$dws_bin" dev app disable --unified-app-id "$exact_app_id" --yes + run_json "devapp raw disable exact readback" "$evidence_dir/devapp-disable-raw-exact-readback.json" \ + "$dws_bin" devapp +get --unified-app-id "$exact_app_id" + assert_jq "devapp raw disable terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-disable-raw.json" + assert_jq "devapp raw disable exact state" '(.data.appStatus | ascii_downcase) == "disabled"' "$evidence_dir/devapp-disable-raw-exact-readback.json" + run_json "devapp exact enable" "$evidence_dir/devapp-enable-exact.json" \ + "$dws_bin" devapp +enable --unified-app-id "$exact_app_id" --yes + run_json "devapp exact enable raw readback" "$evidence_dir/devapp-enable-exact-raw-readback.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + assert_jq "devapp exact enable terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-enable-exact.json" + assert_jq "devapp exact enable raw state" '(.data.appStatus | ascii_downcase) == "normal"' "$evidence_dir/devapp-enable-exact-raw-readback.json" + run_json "devapp exact disable" "$evidence_dir/devapp-disable-exact.json" \ + "$dws_bin" devapp +disable --unified-app-id "$exact_app_id" --yes + run_json "devapp exact disable raw readback" "$evidence_dir/devapp-disable-exact-raw-readback.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + assert_jq "devapp exact disable terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-disable-exact.json" + assert_jq "devapp exact disable raw state" '(.data.appStatus | ascii_downcase) == "disabled"' "$evidence_dir/devapp-disable-exact-raw-readback.json" + run_json "devapp raw enable" "$evidence_dir/devapp-enable-raw.json" \ + "$dws_bin" dev app enable --unified-app-id "$exact_app_id" --yes + run_json "devapp raw enable exact readback" "$evidence_dir/devapp-enable-raw-exact-readback.json" \ + "$dws_bin" devapp +get --unified-app-id "$exact_app_id" + assert_jq "devapp raw enable terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-enable-raw.json" + assert_jq "devapp raw enable exact state" '(.data.appStatus | ascii_downcase) == "normal"' "$evidence_dir/devapp-enable-raw-exact-readback.json" + pass "DEVAPP +disable confirm0/exact+raw terminal-receipt/disabled readback/restored" + pass "DEVAPP +enable confirm0/exact+raw terminal-receipt/normal readback" + + run_json "devapp exact credentials" "$evidence_dir/devapp-credentials-exact.json" \ + "$dws_bin" devapp +credentials-get --unified-app-id "$exact_app_id" + run_json "devapp raw credentials" "$evidence_dir/devapp-credentials-raw.json" \ + "$dws_bin" dev app credentials get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" ' + (input) as $raw | + .ok == true and .outcome == "success" and .data.unifiedAppId == $id and + (.data.appKey | type) == "string" and (.data.appKey | length) > 0 and + (.data.appSecret | type) == "string" and (.data.appSecret | length) > 0 and + $raw.ok == true and $raw.data.unifiedAppId == $id and + .data.appKey == $raw.data.appKey and .data.appSecret == $raw.data.appSecret + ' "$evidence_dir/devapp-credentials-exact.json" "$evidence_dir/devapp-credentials-raw.json" >/dev/null || fail "devapp +credentials-get dual object read" + pass "DEVAPP +credentials-get exact+raw stable-app/client/one-nonempty-secret alignment" + + run_json "devapp raw webapp config" "$evidence_dir/devapp-webapp-config-raw.json" \ + "$dws_bin" dev app webapp config --unified-app-id "$exact_app_id" --homepage-url "https://example.invalid/raw" --yes + run_json "devapp raw webapp exact readback" "$evidence_dir/devapp-webapp-config-raw-exact-readback.json" \ + "$dws_bin" devapp +webapp-get --unified-app-id "$exact_app_id" + assert_jq "devapp raw webapp terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-webapp-config-raw.json" + assert_jq "devapp raw webapp exact readback" '.data.homepageUrl == "https://example.invalid/raw"' "$evidence_dir/devapp-webapp-config-raw-exact-readback.json" + run_json "devapp exact webapp config" "$evidence_dir/devapp-webapp-config-exact.json" \ + "$dws_bin" devapp +webapp-config --unified-app-id "$exact_app_id" --homepage-url "https://example.invalid/exact" --yes + run_json "devapp exact webapp raw readback" "$evidence_dir/devapp-webapp-config-exact-raw-readback.json" \ + "$dws_bin" dev app webapp get --unified-app-id "$exact_app_id" + assert_jq "devapp exact webapp terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-webapp-config-exact.json" + assert_jq "devapp exact webapp raw readback" '.data.homepageUrl == "https://example.invalid/exact"' "$evidence_dir/devapp-webapp-config-exact-raw-readback.json" + pass "DEVAPP +webapp-config confirm0/exact+raw terminal-receipt/URL readback" + run_json "devapp exact webapp get final" "$evidence_dir/devapp-webapp-get-exact.json" \ + "$dws_bin" devapp +webapp-get --unified-app-id "$exact_app_id" + run_json "devapp raw webapp get final" "$evidence_dir/devapp-webapp-get-raw.json" \ + "$dws_bin" dev app webapp get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" '(input) as $raw | .data.unifiedAppId == $id and $raw.data.unifiedAppId == $id and .data.homepageUrl == $raw.data.homepageUrl' "$evidence_dir/devapp-webapp-get-exact.json" "$evidence_dir/devapp-webapp-get-raw.json" >/dev/null || fail "devapp +webapp-get dual object read" + pass "DEVAPP +webapp-get exact+raw stable-app/key-field object alignment" + + run_json "devapp member raw known" "$evidence_dir/devapp-member-known-raw.json" \ + "$dws_bin" dev app member list --unified-app-id "$exact_app_id" + run_json "devapp member exact known" "$evidence_dir/devapp-member-known-exact.json" \ + "$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" + run_json "devapp member exact zero" "$evidence_dir/devapp-member-zero-exact.json" \ + "$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$nonce" + jq -e --arg nonce "$nonce" ' + (input) as $raw | (input) as $zero | + .ok == true and .data.count > 0 and $raw.ok == true and ($raw.data.members | type) == "array" and + ([.data.members[].userId] | sort) == ([$raw.data.members[].userId] | sort) and + $zero.ok == true and $zero.data.count == 0 and ($zero.data.members | length) == 0 and + ([$raw.data.members[].userId] | index($nonce)) == null + ' "$evidence_dir/devapp-member-known-exact.json" "$evidence_dir/devapp-member-known-raw.json" "$evidence_dir/devapp-member-zero-exact.json" >/dev/null || fail "devapp +member-list adapter alignment" + pass "DEVAPP +member-list exact local-ID-filter+raw full-set known/guaranteed-zero stable-member alignment" + + run_json "devapp member fixture exact before" "$evidence_dir/devapp-member-fixture-before-exact.json" \ + "$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$member_user_id" + run_json "devapp member fixture raw before" "$evidence_dir/devapp-member-fixture-before-raw.json" \ + "$dws_bin" dev app member list --unified-app-id "$exact_app_id" + jq -e --arg user "$member_user_id" '(input) as $raw | .data.count == 0 and (.data.members | length) == 0 and ([$raw.data.members[].userId] | index($user)) == null' \ + "$evidence_dir/devapp-member-fixture-before-exact.json" "$evidence_dir/devapp-member-fixture-before-raw.json" >/dev/null || fail "devapp member fixture initially absent" + + expect_confirmation "devapp +member-add confirmation" "$evidence_dir/devapp-member-add-confirm.json" \ + "$dws_bin" devapp +member-add --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" + expect_confirmation "devapp +member-remove confirmation" "$evidence_dir/devapp-member-remove-confirm.json" \ + "$dws_bin" devapp +member-remove --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" + run_json "devapp member fixture exact after guards" "$evidence_dir/devapp-member-fixture-after-guards-exact.json" \ + "$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$member_user_id" + run_json "devapp member fixture raw after guards" "$evidence_dir/devapp-member-fixture-after-guards-raw.json" \ + "$dws_bin" dev app member list --unified-app-id "$exact_app_id" + jq -e --arg user "$member_user_id" '(input) as $raw | .data.count == 0 and (.data.members | length) == 0 and ([$raw.data.members[].userId] | index($user)) == null' \ + "$evidence_dir/devapp-member-fixture-after-guards-exact.json" "$evidence_dir/devapp-member-fixture-after-guards-raw.json" >/dev/null || fail "devapp member confirmations made no writes" + + run_json "devapp member exact add" "$evidence_dir/devapp-member-add-exact.json" \ + "$dws_bin" devapp +member-add --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" --yes + run_json "devapp member exact add raw readback" "$evidence_dir/devapp-member-add-exact-raw-readback.json" \ + "$dws_bin" dev app member list --unified-app-id "$exact_app_id" + jq -e --arg type "$member_type" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "member_add" and .data.resource.count == 1 and .data.resource.memberType == $type' \ + "$evidence_dir/devapp-member-add-exact.json" >/dev/null || fail "devapp member exact add terminal" + jq -e --arg user "$member_user_id" --arg type "$member_type" '([.data.members[] | select(.userId == $user and .memberType == $type)] | length) == 1' \ + "$evidence_dir/devapp-member-add-exact-raw-readback.json" >/dev/null || fail "devapp member exact add raw stable-ID role readback" + + run_json "devapp member raw remove" "$evidence_dir/devapp-member-remove-raw.json" \ + "$dws_bin" dev app member remove --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" --yes + run_json "devapp member raw remove exact readback" "$evidence_dir/devapp-member-remove-raw-exact-readback.json" \ + "$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$member_user_id" + assert_jq "devapp member raw remove terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-member-remove-raw.json" + assert_jq "devapp member raw remove exact absence" '.ok == true and .data.count == 0 and (.data.members | length) == 0' "$evidence_dir/devapp-member-remove-raw-exact-readback.json" + + run_json "devapp member raw add" "$evidence_dir/devapp-member-add-raw.json" \ + "$dws_bin" dev app member add --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" --yes + run_json "devapp member raw add exact readback" "$evidence_dir/devapp-member-add-raw-exact-readback.json" \ + "$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$member_user_id" + assert_jq "devapp member raw add terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-member-add-raw.json" + jq -e --arg user "$member_user_id" --arg type "$member_type" \ + '.ok == true and .data.count == 1 and .data.members[0].userId == $user and .data.members[0].memberType == $type' \ + "$evidence_dir/devapp-member-add-raw-exact-readback.json" >/dev/null || fail "devapp member raw add exact stable-ID role readback" + + run_json "devapp member exact remove" "$evidence_dir/devapp-member-remove-exact.json" \ + "$dws_bin" devapp +member-remove --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" --yes + run_json "devapp member exact remove raw readback" "$evidence_dir/devapp-member-remove-exact-raw-readback.json" \ + "$dws_bin" dev app member list --unified-app-id "$exact_app_id" + assert_jq "devapp member exact remove terminal" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "member_remove"' \ + "$evidence_dir/devapp-member-remove-exact.json" + jq -e --arg user "$member_user_id" '([.data.members[].userId] | index($user)) == null' \ + "$evidence_dir/devapp-member-remove-exact-raw-readback.json" >/dev/null || fail "devapp member exact remove raw absence" + pass "DEVAPP +member-add/+member-remove confirm0/exact+raw terminal-receipt/stable-ID role/absence readback" + + run_json "devapp permission exact known" "$evidence_dir/devapp-permission-known-exact.json" \ + "$dws_bin" devapp +permission-list --unified-app-id "$exact_app_id" --page-size 20 + run_json "devapp permission raw known" "$evidence_dir/devapp-permission-known-raw.json" \ + "$dws_bin" dev app permission list --unified-app-id "$exact_app_id" --page-size 20 + run_json "devapp permission exact zero" "$evidence_dir/devapp-permission-zero-exact.json" \ + "$dws_bin" devapp +permission-list --unified-app-id "$exact_app_id" --keyword "$nonce" --page-size 20 + run_json "devapp permission raw zero" "$evidence_dir/devapp-permission-zero-raw.json" \ + "$dws_bin" dev app permission list --unified-app-id "$exact_app_id" --keyword "$nonce" --page-size 20 + assert_jq "devapp permission dual known/zero alignment" ' + (input) as $raw | (input) as $ze | (input) as $zr | + .ok == true and .data.count > 0 and $raw.ok == true and ($raw.data.items | length) > 0 and + ([.data.permissions[].scopeValue] | sort) == ([$raw.data.items[].scopeValue] | sort) and + $ze.ok == true and $ze.data.count == 0 and ($ze.data.permissions | length) == 0 and + $zr.ok == true and ($zr.data.items | length) == 0 and + .meta.pagination.endpoint_exhausted == $raw.meta.pagination.endpoint_exhausted and + $ze.meta.pagination.endpoint_exhausted == true and $zr.meta.pagination.endpoint_exhausted == true + ' "$evidence_dir/devapp-permission-known-exact.json" "$evidence_dir/devapp-permission-known-raw.json" \ + "$evidence_dir/devapp-permission-zero-exact.json" "$evidence_dir/devapp-permission-zero-raw.json" + pass "DEVAPP +permission-list exact+raw known-nonempty/guaranteed-zero stable-scope/cursor alignment" + + run_json "devapp robot exact initial" "$evidence_dir/devapp-robot-get-exact.json" \ + "$dws_bin" devapp +robot-get --unified-app-id "$exact_app_id" + run_json "devapp robot raw initial" "$evidence_dir/devapp-robot-get-raw.json" \ + "$dws_bin" dev app robot get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" '(input) as $raw | .data.unifiedAppId == $id and $raw.data.unifiedAppId == $id and .data.robotStatus == $raw.data.robotStatus' "$evidence_dir/devapp-robot-get-exact.json" "$evidence_dir/devapp-robot-get-raw.json" >/dev/null || fail "devapp +robot-get initial dual object" + pass "DEVAPP +robot-get exact+raw stable-app/status object alignment" + + run_json "devapp robot raw config" "$evidence_dir/devapp-robot-config-raw.json" \ + "$dws_bin" dev app robot config --unified-app-id "$exact_app_id" --name "$bot_raw" --brief "dual-live-raw" --desc "dual-live-raw" --mode STREAM --add-scope --yes + run_json "devapp robot raw config exact readback" "$evidence_dir/devapp-robot-config-raw-exact-readback.json" \ + "$dws_bin" devapp +robot-get --unified-app-id "$exact_app_id" + assert_jq "devapp robot raw config terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-robot-config-raw.json" + jq -e --arg name "$bot_raw" '.data.name == $name and .data.mode == "STREAM"' "$evidence_dir/devapp-robot-config-raw-exact-readback.json" >/dev/null || fail "devapp robot raw config exact readback" + run_json "devapp robot exact config" "$evidence_dir/devapp-robot-config-exact.json" \ + "$dws_bin" devapp +robot-config --unified-app-id "$exact_app_id" --name "$bot_exact" --brief "dual-live-exact" --desc "dual-live-exact" --mode STREAM --yes + run_json "devapp robot exact config raw readback" "$evidence_dir/devapp-robot-config-exact-raw-readback.json" \ + "$dws_bin" dev app robot get --unified-app-id "$exact_app_id" + assert_jq "devapp robot exact config terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-robot-config-exact.json" + jq -e --arg name "$bot_exact" '.data.name == $name and .data.mode == "STREAM"' "$evidence_dir/devapp-robot-config-exact-raw-readback.json" >/dev/null || fail "devapp robot exact config raw readback" + pass "DEVAPP +robot-config confirm0/exact+raw terminal-receipt/config-field readback" + + run_json "devapp robot raw enable" "$evidence_dir/devapp-robot-enable-raw.json" \ + "$dws_bin" dev app robot enable --unified-app-id "$exact_app_id" --yes + run_json "devapp robot raw enable exact readback" "$evidence_dir/devapp-robot-enable-raw-exact-readback.json" \ + "$dws_bin" devapp +robot-get --unified-app-id "$exact_app_id" + assert_jq "devapp robot raw enable terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-robot-enable-raw.json" + assert_jq "devapp robot raw enable exact state" '.data.robotStatus == "ONLINE"' "$evidence_dir/devapp-robot-enable-raw-exact-readback.json" + run_json "devapp robot exact disable" "$evidence_dir/devapp-robot-disable-exact.json" \ + "$dws_bin" devapp +robot-disable --unified-app-id "$exact_app_id" --yes + run_json "devapp robot exact disable raw readback" "$evidence_dir/devapp-robot-disable-exact-raw-readback.json" \ + "$dws_bin" dev app robot get --unified-app-id "$exact_app_id" + assert_jq "devapp robot exact disable terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-robot-disable-exact.json" + assert_jq "devapp robot exact disable raw state" '.data.robotStatus == "UNCONFIGURED"' "$evidence_dir/devapp-robot-disable-exact-raw-readback.json" + run_json "devapp robot raw reconfigure" "$evidence_dir/devapp-robot-reconfigure-raw.json" \ + "$dws_bin" dev app robot config --unified-app-id "$exact_app_id" --name "$bot_raw" --brief "dual-live-raw" --mode STREAM --yes + run_json "devapp robot exact enable" "$evidence_dir/devapp-robot-enable-exact.json" \ + "$dws_bin" devapp +robot-enable --unified-app-id "$exact_app_id" --yes + run_json "devapp robot exact enable raw readback" "$evidence_dir/devapp-robot-enable-exact-raw-readback.json" \ + "$dws_bin" dev app robot get --unified-app-id "$exact_app_id" + assert_jq "devapp robot exact enable terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-robot-enable-exact.json" + assert_jq "devapp robot exact enable raw state" '.data.robotStatus == "ONLINE"' "$evidence_dir/devapp-robot-enable-exact-raw-readback.json" + run_json "devapp robot raw disable" "$evidence_dir/devapp-robot-disable-raw.json" \ + "$dws_bin" dev app robot disable --unified-app-id "$exact_app_id" --yes + run_json "devapp robot raw disable exact readback" "$evidence_dir/devapp-robot-disable-raw-exact-readback.json" \ + "$dws_bin" devapp +robot-get --unified-app-id "$exact_app_id" + assert_jq "devapp robot raw disable terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-robot-disable-raw.json" + assert_jq "devapp robot raw disable exact state" '.data.robotStatus == "UNCONFIGURED"' "$evidence_dir/devapp-robot-disable-raw-exact-readback.json" + pass "DEVAPP +robot-enable confirm0/exact+raw terminal-receipt/ONLINE readback" + pass "DEVAPP +robot-disable confirm0/exact+raw terminal-receipt/UNCONFIGURED readback" + + run_json "devapp event raw subscribe" "$evidence_dir/devapp-event-subscribe-raw.json" \ + "$dws_bin" dev app event subscribe --unified-app-id "$exact_app_id" --event-codes "$event_raw" --yes + run_json "devapp event raw subscribe exact replay" "$evidence_dir/devapp-event-subscribe-raw-exact-replay.json" \ + "$dws_bin" devapp +event-subscribe --unified-app-id "$exact_app_id" --event-codes "$event_raw" --yes + run_json "devapp event raw subscribe raw readback" "$evidence_dir/devapp-event-subscribe-raw-readback.json" \ + "$dws_bin" dev app event list --unified-app-id "$exact_app_id" --keyword "$event_raw" --page-size 100 + assert_jq "devapp event raw subscribe terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-event-subscribe-raw.json" + assert_jq "devapp event raw subscribe exact replay" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-event-subscribe-raw-exact-replay.json" + jq -e --arg code "$event_raw" '(.data.events | length) == 1 and .data.events[0].eventCode == $code' "$evidence_dir/devapp-event-subscribe-raw-readback.json" >/dev/null || fail "devapp event raw subscribe readback" + run_json "devapp event exact subscribe" "$evidence_dir/devapp-event-subscribe-exact.json" \ + "$dws_bin" devapp +event-subscribe --unified-app-id "$exact_app_id" --event-codes "$event_exact" --yes + run_json "devapp event exact subscribe raw readback" "$evidence_dir/devapp-event-subscribe-exact-raw-readback.json" \ + "$dws_bin" dev app event list --unified-app-id "$exact_app_id" --keyword "$event_exact" --page-size 100 + assert_jq "devapp event exact subscribe terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-event-subscribe-exact.json" + jq -e --arg code "$event_exact" '(.data.events | length) == 1 and .data.events[0].eventCode == $code' "$evidence_dir/devapp-event-subscribe-exact-raw-readback.json" >/dev/null || fail "devapp event exact subscribe raw readback" + pass "DEVAPP +event-subscribe confirm0/exact+raw terminal-receipt/eventCode readback" + + run_json "devapp event exact known" "$evidence_dir/devapp-event-list-known-exact.json" \ + "$dws_bin" devapp +event-list --unified-app-id "$exact_app_id" --keyword "$event_exact" --page-size 100 + run_json "devapp event raw known" "$evidence_dir/devapp-event-list-known-raw.json" \ + "$dws_bin" dev app event list --unified-app-id "$exact_app_id" --keyword "$event_exact" --page-size 100 + run_json "devapp event exact zero" "$evidence_dir/devapp-event-list-zero-exact.json" \ + "$dws_bin" devapp +event-list --unified-app-id "$exact_app_id" --keyword "$nonce" --page-size 100 + run_json "devapp event raw zero" "$evidence_dir/devapp-event-list-zero-raw.json" \ + "$dws_bin" dev app event list --unified-app-id "$exact_app_id" --keyword "$nonce" --page-size 100 + jq -e --arg code "$event_exact" '(input) as $raw | (input) as $ze | (input) as $zr | + .ok == true and .data.count == 1 and .data.events[0].eventCode == $code and + $raw.ok == true and ($raw.data.events | length) == 1 and $raw.data.events[0].eventCode == $code and + .meta.pagination.endpoint_exhausted == true and $raw.meta.pagination.endpoint_exhausted == true and + $ze.ok == true and $ze.data.count == 0 and ($ze.data.events | length) == 0 and + $zr.ok == true and ($zr.data.events | length) == 0 and + $ze.meta.pagination.endpoint_exhausted == true and $zr.meta.pagination.endpoint_exhausted == true' \ + "$evidence_dir/devapp-event-list-known-exact.json" "$evidence_dir/devapp-event-list-known-raw.json" \ + "$evidence_dir/devapp-event-list-zero-exact.json" "$evidence_dir/devapp-event-list-zero-raw.json" >/dev/null || fail "devapp +event-list dual known/zero alignment" + pass "DEVAPP +event-list exact+raw known-nonempty/guaranteed-zero stable-event/cursor alignment" + + run_json "devapp version exact zero before" "$evidence_dir/devapp-version-zero-exact.json" \ + "$dws_bin" devapp +version-list --unified-app-id "$exact_app_id" --page-size 100 + run_json "devapp version raw zero before" "$evidence_dir/devapp-version-zero-raw.json" \ + "$dws_bin" dev app version list --unified-app-id "$exact_app_id" --page-size 100 + assert_jq "devapp version guaranteed zero fixture" '(input) as $raw | .data.count == 0 and (.data.versions | length) == 0 and ($raw.data.items | length) == 0 and .meta.pagination.endpoint_exhausted == true and $raw.meta.pagination.endpoint_exhausted == true' "$evidence_dir/devapp-version-zero-exact.json" "$evidence_dir/devapp-version-zero-raw.json" + run_json "devapp version raw create" "$evidence_dir/devapp-version-create-raw.json" \ + "$dws_bin" dev app version create --unified-app-id "$exact_app_id" --desc "dual-live-raw" --yes + local raw_version_id + raw_version_id=$(jq -er '.data.versionId | select(type == "string" and length > 0)' "$evidence_dir/devapp-version-create-raw.json") || fail "devapp raw version create stable ID" + assert_jq "devapp raw version create terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-version-create-raw.json" + run_json "devapp raw version exact readback" "$evidence_dir/devapp-version-create-raw-exact-readback.json" \ + "$dws_bin" devapp +version-get --unified-app-id "$exact_app_id" --version-id "$raw_version_id" + jq -e --arg app "$exact_app_id" --arg version "$raw_version_id" '.ok == true and .data.unifiedAppId == $app and .data.versionId == $version' "$evidence_dir/devapp-version-create-raw-exact-readback.json" >/dev/null || fail "devapp raw version exact readback" + run_json "devapp version exact create" "$evidence_dir/devapp-version-create-exact.json" \ + "$dws_bin" devapp +version-create --unified-app-id "$exact_app_id" --desc "dual-live-exact" --yes + local exact_version_id + exact_version_id=$(jq -er '.data.resource.versionId | select(type == "string" and length > 0)' "$evidence_dir/devapp-version-create-exact.json") || fail "devapp exact version create stable ID" + assert_jq "devapp exact version create terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-version-create-exact.json" + run_json "devapp exact version raw readback" "$evidence_dir/devapp-version-create-exact-raw-readback.json" \ + "$dws_bin" dev app version get --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + jq -e --arg app "$exact_app_id" --arg version "$exact_version_id" '.ok == true and .data.unifiedAppId == $app and .data.versionId == $version' "$evidence_dir/devapp-version-create-exact-raw-readback.json" >/dev/null || fail "devapp exact version raw readback" + pass "DEVAPP +version-create confirm0/exact+raw terminal-receipt/app+version-ID readback" + + local version_aligned=false + for _ in 1 2 3 4 5; do + run_json "devapp version exact known" "$evidence_dir/devapp-version-known-exact.json" \ + "$dws_bin" devapp +version-list --unified-app-id "$exact_app_id" --page-size 100 + run_json "devapp version raw known" "$evidence_dir/devapp-version-known-raw.json" \ + "$dws_bin" dev app version list --unified-app-id "$exact_app_id" --page-size 100 + if jq -e --arg raw_version "$raw_version_id" --arg exact_version "$exact_version_id" ' + (input) as $raw | + .data.count >= 1 and + ([.data.versions[].versionId] | index($raw_version)) != null and + ([.data.versions[].versionId] | index($exact_version)) != null and + ([.data.versions[].versionId] | sort) == ([$raw.data.items[].versionId] | sort) and + .meta.pagination.endpoint_exhausted == true and $raw.meta.pagination.endpoint_exhausted == true + ' "$evidence_dir/devapp-version-known-exact.json" "$evidence_dir/devapp-version-known-raw.json" >/dev/null 2>&1; then + version_aligned=true + break + fi + sleep 2 + done + if [[ $version_aligned != true ]]; then + jq -n -c --arg raw_version "$raw_version_id" --arg exact_version "$exact_version_id" \ + --slurpfile exact "$evidence_dir/devapp-version-known-exact.json" \ + --slurpfile raw "$evidence_dir/devapp-version-known-raw.json" '{ + diagnostic:"DEVAPP-VERSION-LIST", + exact_count:$exact[0].data.count, + exact_items:($exact[0].data.versions|length), + raw_items:($raw[0].data.items|length), + ids_equal:(([$exact[0].data.versions[].versionId]|sort)==([$raw[0].data.items[].versionId]|sort)), + raw_version_visible:(([$exact[0].data.versions[].versionId]|index($raw_version))!=null), + exact_version_visible:(([$exact[0].data.versions[].versionId]|index($exact_version))!=null), + exact_pagination_type:($exact[0].meta.pagination|type), + raw_pagination_type:($raw[0].meta.pagination|type), + exact_exhausted:$exact[0].meta.pagination.endpoint_exhausted, + raw_exhausted:$raw[0].meta.pagination.endpoint_exhausted + }' >&2 + fail "devapp version known alignment" + fi + pass "DEVAPP +version-list exact+raw known-nonempty/empty-fixture-zero stable-version/cursor alignment" + + run_json "devapp version exact get" "$evidence_dir/devapp-version-get-exact.json" \ + "$dws_bin" devapp +version-get --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + run_json "devapp version raw get" "$evidence_dir/devapp-version-get-raw.json" \ + "$dws_bin" dev app version get --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + jq -e --arg app "$exact_app_id" --arg version "$exact_version_id" '(input) as $raw | .data.unifiedAppId == $app and .data.versionId == $version and $raw.data.unifiedAppId == $app and $raw.data.versionId == $version' "$evidence_dir/devapp-version-get-exact.json" "$evidence_dir/devapp-version-get-raw.json" >/dev/null || fail "devapp +version-get dual object" + pass "DEVAPP +version-get exact+raw stable-app+version-ID object alignment" + + run_json "devapp version exact approval" "$evidence_dir/devapp-version-approval-exact.json" \ + "$dws_bin" devapp +version-check-approval --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + run_json "devapp version raw approval" "$evidence_dir/devapp-version-approval-raw.json" \ + "$dws_bin" dev app version check-approval --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + jq -e --arg app "$exact_app_id" --arg version "$exact_version_id" '(input) as $raw | (.outcome == "success" or .outcome == "pending") and .data.unifiedAppId == $app and .data.versionId == $version and ($raw.outcome == "success" or $raw.outcome == "pending") and $raw.data.unifiedAppId == $app and $raw.data.versionId == $version' "$evidence_dir/devapp-version-approval-exact.json" "$evidence_dir/devapp-version-approval-raw.json" >/dev/null || fail "devapp +version-check-approval dual object" + pass "DEVAPP +version-check-approval exact+raw stable-app+version-ID pending/terminal alignment" + + run_json "devapp version exact status" "$evidence_dir/devapp-version-status-exact.json" \ + "$dws_bin" devapp +version-status --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + run_json "devapp version raw status" "$evidence_dir/devapp-version-status-raw.json" \ + "$dws_bin" dev app version status --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + jq -e --arg app "$exact_app_id" --arg version "$exact_version_id" '(input) as $raw | (.outcome == "success" or .outcome == "pending") and .data.unifiedAppId == $app and .data.versionId == $version and ($raw.outcome == "success" or $raw.outcome == "pending") and $raw.data.unifiedAppId == $app and $raw.data.versionId == $version' "$evidence_dir/devapp-version-status-exact.json" "$evidence_dir/devapp-version-status-raw.json" >/dev/null || fail "devapp +version-status dual object" + pass "DEVAPP +version-status exact+raw stable-app+version-ID status alignment" + + run_json "devapp raw delete" "$evidence_dir/devapp-delete-raw.json" \ + "$dws_bin" dev app delete --unified-app-id "$raw_app_id" --confirm-name "$raw_app_name" --yes + assert_jq "devapp raw delete terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-delete-raw.json" + run_json "devapp raw delete exact absence" "$evidence_dir/devapp-delete-raw-exact-absence.json" \ + "$dws_bin" devapp +list --name "$raw_app_name" --page-size 20 + run_json "devapp raw delete raw absence" "$evidence_dir/devapp-delete-raw-raw-absence.json" \ + "$dws_bin" dev app list --name "$raw_app_name" --page-size 20 + assert_jq "devapp raw delete dual absence" '(input) as $raw | .data.count == 0 and (.data.apps | length) == 0 and ($raw.data.items | length) == 0' "$evidence_dir/devapp-delete-raw-exact-absence.json" "$evidence_dir/devapp-delete-raw-raw-absence.json" + raw_app_id="" + + run_json "devapp exact delete" "$evidence_dir/devapp-delete-exact.json" \ + "$dws_bin" devapp +delete --unified-app-id "$exact_app_id" --yes + assert_jq "devapp exact delete terminal" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "delete"' "$evidence_dir/devapp-delete-exact.json" + run_json "devapp exact delete exact absence" "$evidence_dir/devapp-delete-exact-exact-absence.json" \ + "$dws_bin" devapp +list --name "$exact_updated_name" --page-size 20 + run_json "devapp exact delete raw absence" "$evidence_dir/devapp-delete-exact-raw-absence.json" \ + "$dws_bin" dev app list --name "$exact_updated_name" --page-size 20 + assert_jq "devapp exact delete dual absence" '(input) as $raw | .data.count == 0 and (.data.apps | length) == 0 and ($raw.data.items | length) == 0' "$evidence_dir/devapp-delete-exact-exact-absence.json" "$evidence_dir/devapp-delete-exact-raw-absence.json" + exact_app_id="" + pass "DEVAPP +delete confirm0/exact+raw terminal-receipt/dual absence cleanup" + + pass "DEVAPP-INVENTORY public=25 dual-layer-pass=25 unavailable=5 cleanup=zero-residual" +} + +run_agoal_gate() { + assert_public_surface agoal "+contract-fields ++obj-template-list ++report-statistics-list ++report-submit-detail ++user-rules" + local nonce="DWS-GUARANTEED-ZERO-8F3D2C4A" + local future_date="2099-12-31" + + run_json "agoal report statistics exact known" "$evidence_dir/agoal-report-known-exact.json" \ + "$dws_bin" agoal +report-statistics-list + run_json "agoal report statistics raw known" "$evidence_dir/agoal-report-known-raw.json" \ + "$dws_bin" agoal report list-statistics + assert_jq "agoal report statistics known alignment" ' + (input) as $raw | + .ok == true and .outcome == "success" and .data.count > 0 and + $raw.success == true and ($raw.content | type) == "array" and + (.data.count == ($raw.content | length)) and + ([.data.statistics[].templateId] | sort) == ([$raw.content[].templateId] | sort) + ' "$evidence_dir/agoal-report-known-exact.json" "$evidence_dir/agoal-report-known-raw.json" + run_json "agoal report statistics exact zero" "$evidence_dir/agoal-report-zero-exact.json" \ + "$dws_bin" agoal +report-statistics-list --keyword "$nonce" + run_json "agoal report statistics raw zero" "$evidence_dir/agoal-report-zero-raw.json" \ + "$dws_bin" agoal report list-statistics --keyword "$nonce" + assert_jq "agoal report statistics zero alignment" ' + (input) as $raw | + .ok == true and .outcome == "success" and .data.count == 0 and + (.data.statistics | type) == "array" and (.data.statistics | length) == 0 and + $raw.success == true and ($raw.content | type) == "array" and ($raw.content | length) == 0 + ' "$evidence_dir/agoal-report-zero-exact.json" "$evidence_dir/agoal-report-zero-raw.json" + pass "AGOAL +report-statistics-list exact+raw known-nonempty/guaranteed-zero stable-template alignment" + + run_json "agoal template exact known" "$evidence_dir/agoal-template-known-exact.json" \ + "$dws_bin" agoal +obj-template-list --page 1 --page-size 20 + run_json "agoal template raw known" "$evidence_dir/agoal-template-known-raw.json" \ + "$dws_bin" agoal obj-template list --page 1 --page-size 20 + assert_jq "agoal template known alignment" ' + (input) as $raw | + .ok == true and .outcome == "success" and .data.count > 0 and + $raw.success == true and ($raw.content.result | type) == "array" and + .data.page == $raw.content.page and .data.pageSize == $raw.content.pageSize and + .data.totalCount == $raw.content.totalCount and + ([.data.templates[] | (.id // .templateId)] | sort) == + ([$raw.content.result[] | (.id // .templateId)] | sort) + ' "$evidence_dir/agoal-template-known-exact.json" "$evidence_dir/agoal-template-known-raw.json" + run_json "agoal template exact zero" "$evidence_dir/agoal-template-zero-exact.json" \ + "$dws_bin" agoal +obj-template-list --keyword "$nonce" --page 1 --page-size 20 + run_json "agoal template raw zero" "$evidence_dir/agoal-template-zero-raw.json" \ + "$dws_bin" agoal obj-template list --keyword "$nonce" --page 1 --page-size 20 + assert_jq "agoal template zero alignment" ' + (input) as $raw | + .ok == true and .outcome == "success" and .data.count == 0 and .data.totalCount == 0 and + (.data.templates | type) == "array" and (.data.templates | length) == 0 and + $raw.success == true and ($raw.content.result | type) == "array" and + ($raw.content.result | length) == 0 and $raw.content.totalCount == 0 and + .data.page == $raw.content.page and .data.pageSize == $raw.content.pageSize + ' "$evidence_dir/agoal-template-zero-exact.json" "$evidence_dir/agoal-template-zero-raw.json" + pass "AGOAL +obj-template-list exact+raw known-nonempty/guaranteed-zero stable-ID/page alignment" + + run_json "agoal contract fields raw" "$evidence_dir/agoal-fields-raw.json" \ + "$dws_bin" agoal contract fields + local field_id + field_id=$(jq -er '.content[0].id | select(type == "string" and length > 0)' "$evidence_dir/agoal-fields-raw.json") || fail "agoal contract fields raw fixture" + run_json "agoal contract fields exact known" "$evidence_dir/agoal-fields-known-exact.json" \ + "$dws_bin" agoal +contract-fields --keyword "$field_id" + run_json "agoal contract fields exact zero" "$evidence_dir/agoal-fields-zero-exact.json" \ + "$dws_bin" agoal +contract-fields --keyword "$nonce" + jq -e --arg field_id "$field_id" --arg nonce "$nonce" ' + (input) as $zero | (input) as $raw | + .ok == true and .outcome == "success" and .data.count == 1 and + .data.fields[0].id == $field_id and + $zero.ok == true and $zero.outcome == "success" and $zero.data.count == 0 and + ($zero.data.fields | length) == 0 and + $raw.success == true and ($raw.content | type) == "array" and ($raw.content | length) > 0 and + ([$raw.content[].id] | index($field_id)) != null and + ([$raw.content[] | [.id,.code,.title,.category,.type] | map(select(type == "string")) | join(" ") | ascii_downcase] | + map(contains($nonce | ascii_downcase)) | any) == false + ' "$evidence_dir/agoal-fields-known-exact.json" "$evidence_dir/agoal-fields-zero-exact.json" "$evidence_dir/agoal-fields-raw.json" >/dev/null 2>&1 || fail "agoal contract fields adapter alignment" + pass "AGOAL +contract-fields exact local-filter+raw full-set known/guaranteed-zero stable-ID alignment" + + run_json "agoal user rules raw" "$evidence_dir/agoal-rules-raw.json" \ + "$dws_bin" agoal user rules + local rule_id + rule_id=$(jq -er '.content.rules[0].id | select(type == "string" and length > 0)' "$evidence_dir/agoal-rules-raw.json") || fail "agoal user rules raw fixture" + run_json "agoal user rules exact known" "$evidence_dir/agoal-rules-known-exact.json" \ + "$dws_bin" agoal +user-rules --rule-id "$rule_id" + run_json "agoal user rules exact zero" "$evidence_dir/agoal-rules-zero-exact.json" \ + "$dws_bin" agoal +user-rules --rule-id "$nonce" + jq -e --arg rule_id "$rule_id" --arg nonce "$nonce" ' + (input) as $zero | (input) as $raw | + .ok == true and .outcome == "success" and .data.count == 1 and .data.rules[0].id == $rule_id and + $zero.ok == true and $zero.outcome == "success" and $zero.data.count == 0 and + ($zero.data.rules | length) == 0 and + $raw.success == true and ($raw.content.rules | type) == "array" and ($raw.content.rules | length) > 0 and + ([$raw.content.rules[].id] | index($rule_id)) != null and + ([$raw.content.rules[].id] | index($nonce)) == null + ' "$evidence_dir/agoal-rules-known-exact.json" "$evidence_dir/agoal-rules-zero-exact.json" "$evidence_dir/agoal-rules-raw.json" >/dev/null 2>&1 || fail "agoal user rules adapter alignment" + pass "AGOAL +user-rules exact local-filter+raw full-set known/guaranteed-zero stable-rule alignment" + + local template_id="" + local submit_state="" + while IFS= read -r candidate_template; do + for candidate_state in ON_TIME LATE NOT_SUBMITTED; do + run_json "agoal submission raw fixture scan" "$evidence_dir/agoal-submit-scan.json" \ + "$dws_bin" agoal report submit-detail --template-id "$candidate_template" --submit-state "$candidate_state" --page 1 --page-size 20 --timeout 60 + if jq -e '.success == true and (.content.result | type) == "array" and (.content.result | length) > 0' "$evidence_dir/agoal-submit-scan.json" >/dev/null; then + template_id=$candidate_template + submit_state=$candidate_state + cp "$evidence_dir/agoal-submit-scan.json" "$evidence_dir/agoal-submit-known-raw.json" + break 2 + fi + done + done < <(jq -r '.content[].templateId' "$evidence_dir/agoal-report-known-raw.json") + [[ -n $template_id && -n $submit_state ]] || fail "agoal submission known-nonempty fixture" + run_json "agoal submission exact known" "$evidence_dir/agoal-submit-known-exact.json" \ + "$dws_bin" agoal +report-submit-detail --template-id "$template_id" --submit-state "$submit_state" --page 1 --page-size 20 --timeout 60 + assert_jq "agoal submission known alignment" ' + (input) as $raw | + def stable_id: (.reportId // .user.dingUserId // .user.id); + .ok == true and .outcome == "success" and .data.count > 0 and + .data.page == $raw.content.page and .data.pageSize == $raw.content.pageSize and + .data.totalCount == $raw.content.totalCount and + ([.data.submissions[] | stable_id] | sort) == ([$raw.content.result[] | stable_id] | sort) + ' "$evidence_dir/agoal-submit-known-exact.json" "$evidence_dir/agoal-submit-known-raw.json" + run_json "agoal submission exact guaranteed zero" "$evidence_dir/agoal-submit-zero-exact.json" \ + "$dws_bin" agoal +report-submit-detail --template-id "$template_id" --submit-state ON_TIME --query-date "$future_date" --page 1 --page-size 20 --timeout 60 + run_json "agoal submission raw guaranteed zero" "$evidence_dir/agoal-submit-zero-raw.json" \ + "$dws_bin" agoal report submit-detail --template-id "$template_id" --submit-state ON_TIME --query-date "$future_date" --page 1 --page-size 20 --timeout 60 + assert_jq "agoal submission guaranteed zero alignment" ' + (input) as $raw | + .ok == true and .outcome == "success" and .data.count == 0 and .data.totalCount == 0 and + (.data.submissions | type) == "array" and (.data.submissions | length) == 0 and + $raw.success == true and ($raw.content.result | type) == "array" and + ($raw.content.result | length) == 0 and $raw.content.totalCount == 0 and + .data.page == $raw.content.page and .data.pageSize == $raw.content.pageSize + ' "$evidence_dir/agoal-submit-zero-exact.json" "$evidence_dir/agoal-submit-zero-raw.json" + pass "AGOAL +report-submit-detail exact+raw known-nonempty/future guaranteed-zero stable-user/page alignment" + + pass "AGOAL-INVENTORY public=5 dual-layer-pass=5 unavailable=11" +} + +if [[ $selected_product == all || $selected_product == dev ]]; then + run_dev_gate +fi +if [[ $selected_product == all || $selected_product == devapp ]]; then + # Added below after the Agoal proof block so product execution remains + # Dev -> DevApp -> Agoal when `all` is selected. + run_devapp_gate +fi +if [[ $selected_product == all || $selected_product == agoal ]]; then + run_agoal_gate +fi diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index 429d32bb..8d8a416b 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -40,6 +40,8 @@ SEMANTIC_PATHS = [ ROOT / "internal" / "shortcut" / "semantic_catalog_devdoc.json", ROOT / "internal" / "shortcut" / "semantic_catalog_hrbrain.json", ROOT / "internal" / "shortcut" / "semantic_catalog_pat.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_devapp.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_agoal.json", ] diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index fbfb3458..af499c13 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -43,13 +43,14 @@ cli_version: ">=1.0.15" | 服务 | shortcut 数 | multi skill | |---|---:|---| +| `agoal` | 5 | `—` | | `aisearch` | 1 | `—` | | `aitable` | 100 | `dingtalk-aitable` | | `attendance` | 8 | `dingtalk-misc` | | `calendar` | 27 | `dingtalk-calendar` | | `chat` | 98 | `dingtalk-chat` | | `contact` | 13 | `dingtalk-contact` | -| `devapp` | 19 | `dingtalk-misc` | +| `devapp` | 25 | `dingtalk-misc` | | `ding` | 1 | `dingtalk-misc` | | `doc` | 45 | `dingtalk-doc` | | `drive` | 28 | `dingtalk-drive` | diff --git a/skills/multi/dingtalk-misc/references/devapp.md b/skills/multi/dingtalk-misc/references/devapp.md index 1f39d076..3d9d5032 100644 --- a/skills/multi/dingtalk-misc/references/devapp.md +++ b/skills/multi/dingtalk-misc/references/devapp.md @@ -25,19 +25,25 @@ | Shortcut | 风险 | 适用场景 | |---|---|---| | `dws devapp +create` | write | 创建开放平台企业内部应用 | +| `dws devapp +credentials-get` | read | 读取开放平台应用凭证 | | `dws devapp +delete` | high-risk-write | 删除开放平台企业内部应用(不可逆) | | `dws devapp +disable` | high-risk-write | 停用开放平台企业内部应用 | | `dws devapp +enable` | write | 启用开放平台企业内部应用 | -| `dws devapp +event-list` | read | 查询应用已订阅的事件列表 | +| `dws devapp +event-list` | read | 查询应用可用事件目录与订阅状态 | +| `dws devapp +event-subscribe` | write | 订阅应用事件回调 | | `dws devapp +get` | read | 查询开放平台企业内部应用详情 | | `dws devapp +list` | read | 查询开放平台企业内部应用列表 | | `dws devapp +member-add` | write | 添加开放平台应用成员 | | `dws devapp +member-list` | read | 查询开放平台应用成员 | | `dws devapp +member-remove` | high-risk-write | 移除开放平台应用成员 | | `dws devapp +permission-list` | read | 查询开放平台应用权限列表 | +| `dws devapp +robot-config` | write | 创建或更新现有应用的机器人配置(upsert) | +| `dws devapp +robot-disable` | high-risk-write | 停用现有应用的机器人能力 | +| `dws devapp +robot-enable` | write | 启用现有应用机器人能力(纯启用,无需配置字段) | | `dws devapp +robot-get` | read | 查询现有应用的机器人配置 | | `dws devapp +update` | write | 修改开放平台企业内部应用基础信息 | | `dws devapp +version-check-approval` | read | 预检版本发布是否需要审批(不实际发布) | +| `dws devapp +version-create` | write | 基于当前配置创建应用新版本 | | `dws devapp +version-get` | read | 查询指定版本详情 | | `dws devapp +version-list` | read | 分页查询应用版本列表 | | `dws devapp +version-status` | read | 查询版本发布/审批状态 |